@awebai/oats 0.22.1 → 0.22.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -14,7 +14,7 @@ specialist, a maintainer, a reviewer, a package owner, or any other role, each
14
14
  with a precise curriculum, durable knowledge, and a full provider-native
15
15
  session you can enter and steer.
16
16
 
17
- OATS works with **Pi** and **Claude Code**. A team may mix providers and models
17
+ OATS launches **Pi**, **Claude Code**, and **Codex**. A team may mix providers and models
18
18
  while sharing the same souls, package and config contracts, instance
19
19
  lifecycle, and coordination topology. On machines where `oats setup` has run,
20
20
  the append-only, searchable **turn record** captures supported local transcripts
@@ -45,7 +45,7 @@ designs.
45
45
  instantiated many times without losing its identity or accumulated
46
46
  expertise.
47
47
  - **Instances are real sessions, not hidden subagent calls.** Each instance is
48
- a disposable incarnation with a full Pi or Claude Code session hosted in
48
+ a disposable incarnation with a full Pi, Claude Code, or Codex session hosted in
49
49
  tmux, an explicit task, its own home, and a repository or workspace view.
50
50
  You can attach to it, steer it, message it, stop it, and inspect exactly
51
51
  what it received.
@@ -145,7 +145,8 @@ template discovery curtailed while operator-configured extensions remain
145
145
  enabled. Claude Code keeps the operator's settings, skills, plugins, MCP,
146
146
  hooks, and memory, and OATS adds its canonical composed resources. The
147
147
  guarantee is an exact OATS-managed curriculum, not identical ambient behavior
148
- across providers.
148
+ across providers. Codex uses native instructions, skills and approval settings;
149
+ its launch support currently requires agents to check `aw` themselves for new messages.
149
150
 
150
151
  ### Configuration and layers
151
152
 
@@ -361,3 +362,9 @@ AGENTS.md, Agent Skills, and OKF.
361
362
  ## License
362
363
 
363
364
  [MIT](LICENSE) © 2026 OATS Framework
365
+
366
+ Session backends and unattended launches are described in
367
+ [execution targets](docs/execution-targets.md). Use `oats spawn <soul> --backend
368
+ herdr --yolo` for a Herdr-hosted unattended Codex/Claude session, or put
369
+ `yolo: true` in the scope's oats-config.yaml. Aweb owns shared event delivery;
370
+ terminal transport alone does not enable a messaging broker.
package/bin/oats.mjs CHANGED
@@ -31,7 +31,7 @@ import {
31
31
  resolveOatsConfig, resolveWorkMode, composeInstanceAgentsMd, parseYamlNested, assertSafeConfigValue, assertSafeConfigWriteKey, stripInternalAnnotations, withConfigFile, packagedInject, teamAgentRoots,
32
32
  findTeamAgent, findTeamInstance, findCapabilityAgent, findInstanceHome, listCapabilityAgents, workspaceOf,
33
33
  ensureRoot, findRoot, findAgent, listAgents, listInstances, listAgentDefs, createAgent as coreCreateAgent,
34
- spawnInstance, retireInstance, upsertLocalAgent, defaultRepo, RELATIONS,
34
+ spawnInstance, retireInstance, inspectInstanceSession, inputInstanceSession, attachInstanceSession, upsertLocalAgent, defaultRepo, RELATIONS,
35
35
  } from "../lib/core.mjs";
36
36
  import {
37
37
  aggregateMissingRequirements, applyFromOasScope, beginRunJournal, discoverMigrationScopes, discoverOasScopes, discoverWorkspaceScopes, planFromOasScope,
@@ -39,6 +39,8 @@ import {
39
39
  assertNoSymlinkedParents, copyFileAtomic, writeFileAtomic,
40
40
  runRequirementInstall, selectConfigTemplate, validateConfigTemplate, writeAdoptedTemplate,
41
41
  } from "../lib/packages.mjs";
42
+ import { attachArgv, checkRemote, forgetSnapshot, getServer, inspectRemote, listSnapshots, readServers, rosterGroups, routeCommand, targetOf, validateServer, writeServers, SERVERS_FILE } from "../lib/servers.mjs";
43
+ import { spawnSync as spawnSyncProc } from "node:child_process";
42
44
 
43
45
  const args = process.argv.slice(2);
44
46
  const cmd = args[0];
@@ -47,6 +49,15 @@ const flag = (name) => {
47
49
  const i = args.indexOf(`--${name}`);
48
50
  return i >= 0 ? (args[i + 1] && !args[i + 1].startsWith("--") ? args[i + 1] : true) : undefined;
49
51
  };
52
+ function yoloFlag() {
53
+ if (args.includes("--yolo") && args.includes("--no-yolo")) cmdFail("E_BAD_ARGS", "choose --yolo or --no-yolo, not both");
54
+ return args.includes("--yolo") ? true : args.includes("--no-yolo") ? false : undefined;
55
+ }
56
+ function valueFlag(name) {
57
+ const value = flag(name);
58
+ if (value === true) cmdFail("E_BAD_ARGS", `--${name} needs a value`);
59
+ return value;
60
+ }
50
61
  const die = (msg) => { console.error(`oats: ${msg}`); process.exit(1); };
51
62
  /** Resolve the --dir flag with central validation: a value-taking flag given
52
63
  * no value (flag() → true) is E_BAD_ARGS inside the JSON boundary, never an
@@ -2602,7 +2613,10 @@ function status() {
2602
2613
  console.log(` ${a.name}${a.kind === "local" ? " (local)" : ""} [work: ${a.work || "checkout"}, repo: ${a.repo || "?"}]`);
2603
2614
  if (a.description) console.log(` ${a.description}`);
2604
2615
  for (const i of a.instances) {
2605
- console.log(` • ${i.instance} ${i.running ? "RUNNING" : "idle"} (branch ${i.branch || "?"}, ${i.work || "?"})`);
2616
+ console.log(` • ${i.instance} ${i.retirePending ? "RETIRING" : i.running ? "RUNNING" : "idle"} (branch ${i.branch || "?"}, ${i.work || "?"})`);
2617
+ }
2618
+ for (const f of a.retireFailures || []) {
2619
+ console.log(` ! deferred retirement of ${f.instance} FAILED${f.completedAt ? ` at ${f.completedAt}` : ""}: ${f.error || (f.incomplete || []).join("; ") || "see result file"} — retry with \`oats retire ${f.instance}\``);
2606
2620
  }
2607
2621
  }
2608
2622
  const defs = listAgentDefs(process.cwd());
@@ -2624,7 +2638,8 @@ function statusTeam() {
2624
2638
  if (!agents.length) { console.log(" (no agents)"); continue; }
2625
2639
  for (const a of agents) {
2626
2640
  console.log(` ${a.name}${a.kind === "local" ? " (local)" : ""}${a.description ? ` — ${a.description}` : ""}`);
2627
- for (const i of a.instances) console.log(` • ${i.instance} ${i.running ? "RUNNING" : "idle"}`);
2641
+ for (const i of a.instances) console.log(` • ${i.instance} ${i.retirePending ? "RETIRING" : i.running ? "RUNNING" : "idle"}`);
2642
+ for (const f of a.retireFailures || []) console.log(` ! deferred retirement of ${f.instance} FAILED: ${f.error || (f.incomplete || []).join("; ") || "see result file"} — retry with \`oats retire ${f.instance}\``);
2628
2643
  }
2629
2644
  }
2630
2645
  }
@@ -2633,8 +2648,11 @@ function spawnCmd() {
2633
2648
  // JSON mode: contract envelope, stable error codes, stderr-only progress.
2634
2649
  const bail = (code, msg) => (JSON_MODE ? jsonFail(code, msg) : die(msg));
2635
2650
  const note = (msg) => (JSON_MODE ? console.error(msg) : console.log(msg));
2651
+ const yolo = yoloFlag();
2652
+ const backend = valueFlag("backend"), herdrSocket = valueFlag("herdr-socket");
2653
+ if (backend !== undefined && !["tmux", "herdr"].includes(backend)) bail("E_BAD_ARGS", "--backend must be tmux or herdr");
2636
2654
  const name = args[1];
2637
- if (!name || name.startsWith("--")) bail("E_USAGE", "usage: oats spawn <agent> [--task <text>|--task-file <f>] [--purpose <slug>] [--relation child|sibling|parent|unrelated --relative-to <instance> [--relative-root <agents-root>]] [--parent <instance>] [--repo <r>] [--work worktree|checkout|attached|workspace] [--work-dir <owner-work>] [--runtime pi|claude] [--model <m>] [--branch <b>] [--instructions-file <f>|--def-file <f>] [--no-launch] [--json]");
2655
+ if (!name || name.startsWith("--")) bail("E_USAGE", "usage: oats spawn <agent> [--task <text>|--task-file <f>] [--purpose <slug>] [--relation child|sibling|parent|unrelated --relative-to <instance> [--relative-root <agents-root>]] [--parent <instance>] [--repo <r>] [--work worktree|checkout|attached|workspace] [--work-dir <owner-work>] [--runtime pi|claude|codex] [--backend tmux|herdr] [--herdr-socket <path>] [--yolo|--no-yolo] [--model <m>] [--branch <b>] [--instructions-file <f>|--def-file <f>] [--no-launch] [--json]");
2638
2656
  // Retired boundary flags (maintainer transport ruling): fail LOUDLY before
2639
2657
  // ANY side effect — including root discovery and local-agent upsert (an
2640
2658
  // --instructions-file spawn must not scaffold/overwrite a local soul before
@@ -2676,7 +2694,7 @@ function spawnCmd() {
2676
2694
  } else if (!agent || agent.kind === "local") {
2677
2695
  agent = upsertLocalAgent(root, {
2678
2696
  name, file: defFile, instructions: instrFile ? readFileSync(instrFile, "utf8") : undefined,
2679
- repo: flag("repo"), work: flag("work"), runtime: flag("runtime"), model: flag("model"),
2697
+ repo: flag("repo"), work: flag("work"), runtime: flag("runtime"), model: flag("model"), yolo: yoloFlag(),
2680
2698
  });
2681
2699
  } else {
2682
2700
  bail("E_BAD_ARGS", `"${name}" is a persistent agent — spawn it without --instructions-file/--def-file`);
@@ -2725,7 +2743,7 @@ function spawnCmd() {
2725
2743
  r = spawnInstance(root, agent, {
2726
2744
  purpose: flag("purpose"), task: taskText, taskFile: taskFileFlag, relation, relativeTo, relativeRoot,
2727
2745
  repo: flag("repo") || agent.repo || defaultRepo(workspaceOf(root)) || defaultRepo(process.cwd()),
2728
- work: flag("work"), workDir: flag("work-dir"), runtime: flag("runtime"), model: flag("model"), branch: flag("branch"),
2746
+ work: flag("work"), workDir: flag("work-dir"), runtime: flag("runtime"), backend, herdrSocket, yolo, model: flag("model"), branch: flag("branch"),
2729
2747
  launch: !args.includes("--no-launch"),
2730
2748
  });
2731
2749
  } catch (e) {
@@ -2745,10 +2763,12 @@ function spawnCmd() {
2745
2763
  model: r.model || null, parent: r.parentInstance || null,
2746
2764
  sibling: r.siblingInstance || null, relation: r.relation || null,
2747
2765
  spawnOrigin: r.spawnOrigin, attach: r.attach,
2766
+ ...(r.sessionTarget ? { sessionTarget: r.sessionTarget } : {}),
2767
+ ...(r.yolo !== undefined ? { yolo: r.yolo } : {}),
2748
2768
  });
2749
2769
  return;
2750
2770
  }
2751
- console.log(`Spawned ${r.instance} (${r.work}${r.branch ? `, branch ${r.branch}` : ""})${r.launched ? ` — tmux window "${r.tmux.window}"` : " — not launched"}`);
2771
+ console.log(`Spawned ${r.instance} (${r.work}${r.branch ? `, branch ${r.branch}` : ""})${r.launched ? r.sessionTarget ? ` — Herdr pane "${r.sessionTarget.paneId}"` : ` — tmux window "${r.tmux.window}"` : " — not launched"}`);
2752
2772
  console.log(` home: ${shortPath(r.home)}`);
2753
2773
  if (!r.launched) console.log(` launch: (cd ${shortPath(r.home)} && ${r.command})`);
2754
2774
  for (const w of r.warnings || []) console.log(` WARNING: ${w}`);
@@ -2757,7 +2777,12 @@ function spawnCmd() {
2757
2777
 
2758
2778
  function retireCmd() {
2759
2779
  const name = args[1];
2760
- if (!name || name.startsWith("--")) die("usage: oats retire <instance> [--self] [--delete-branch] [--keep-dir] [--force] [--json]");
2780
+ if (!name || name.startsWith("--")) die("usage: oats retire <instance> [--home <path>] [--self] [--delete-branch] [--keep-dir] [--force] [--json]");
2781
+ let homeFlag = flag("home");
2782
+ if (homeFlag === true) die("--home needs the instance home path");
2783
+ // The calling instance knows its own home: self-retire never needs to
2784
+ // disambiguate a same-named twin by hand.
2785
+ if (homeFlag === undefined && process.env.OATS_INSTANCE_HOME && (process.env.PI_AGENT_INSTANCE === name || process.env.OATS_INSTANCE === name)) homeFlag = process.env.OATS_INSTANCE_HOME;
2761
2786
  const isSelf = process.env.PI_AGENT_INSTANCE === name || process.env.OATS_INSTANCE === name;
2762
2787
  if (isSelf && !args.includes("--self")) die(`"${name}" is the calling instance — self-retire is irreversible; if your task is complete and you were told to retire, re-run with --self (finish your memory files FIRST; your session dies ~8s after)`);
2763
2788
  if (!isSelf && args.includes("--self")) die(`--self given but "${name}" is not the calling instance`);
@@ -2765,9 +2790,20 @@ function retireCmd() {
2765
2790
  // Cross-repo: the instance may home in a sibling repo of the team scope.
2766
2791
  if (!listAgents(root).some((a) => existsSync(join(a._dir, "instances", name)))) {
2767
2792
  const hit = findTeamInstance(dirFlag(), name);
2768
- if (hit && resolve(hit.root) !== resolve(root)) { root = hit.root; console.log(`(cross-repo: instance homes at ${shortPath(root)})`); }
2793
+ // Stdout carries only the envelope in JSON mode (the Desktop parses it).
2794
+ if (hit && resolve(hit.root) !== resolve(root)) { root = hit.root; (args.includes("--json") ? console.error : console.log)(`(cross-repo: instance homes at ${shortPath(root)})`); }
2795
+ }
2796
+ const r = retireInstance(root, name, { home: homeFlag, self: isSelf, deleteBranch: args.includes("--delete-branch"), keepDir: args.includes("--keep-dir"), force: args.includes("--force") });
2797
+ // Deferred self-retire: nothing has been inspected, run, or removed yet. The
2798
+ // caller's window dies first; a detached process then retires the instance
2799
+ // as an external operator and writes its outcome beside the home.
2800
+ if (r.deferred) {
2801
+ if (args.includes("--json")) { console.log(JSON.stringify(r, null, 2)); return; }
2802
+ console.log(`Retirement of ${r.retired} (agent ${r.agent}) is ${r.alreadyScheduled ? "already " : ""}scheduled — say any goodbyes now.`);
2803
+ console.log(` in ~${r.completesInSec}s a detached completion quiesces this runtime (that is what ends this window), preserves work, runs retire hooks and removes the home`);
2804
+ console.log(` if the completion fails, this window stays, the failure shows in \`oats status\` and at ${shortPath(r.resultPath)}, and \`oats retire ${r.retired}\` retries it`);
2805
+ return;
2769
2806
  }
2770
- const r = retireInstance(root, name, { self: isSelf, deleteBranch: args.includes("--delete-branch"), keepDir: args.includes("--keep-dir"), force: args.includes("--force") });
2771
2807
  // Forced removal past an incomplete cleanup: the home is gone because the
2772
2808
  // operator said so, but the external state it owed is still out there and
2773
2809
  // nobody else will mention it again.
@@ -2795,13 +2831,34 @@ function retireCmd() {
2795
2831
  if (isSelf) console.log("This window dies in ~8s — say any goodbyes now.");
2796
2832
  }
2797
2833
 
2834
+ async function sessionCmd() {
2835
+ try {
2836
+ const home = flag("home");
2837
+ let result;
2838
+ if (args[1] === "attach") {
2839
+ if (JSON_MODE) throw Object.assign(new Error("session attach is interactive; omit --json"), { code: "E_BAD_ARGS" });
2840
+ process.exitCode = await attachInstanceSession(home);
2841
+ return;
2842
+ }
2843
+ if (args[1] === "inspect") result = inspectInstanceSession(home);
2844
+ else if (args[1] === "input") {
2845
+ const file = flag("text-file");
2846
+ if (file === true) throw Object.assign(new Error("--text-file needs a path"), { code: "E_BAD_ARGS" });
2847
+ if (!file && process.stdin.isTTY) throw Object.assign(new Error("provide --text-file or pipe input on stdin"), { code: "E_BAD_ARGS" });
2848
+ result = inputInstanceSession(home, readFileSync(file || 0, "utf8"));
2849
+ } else throw Object.assign(new Error("usage: oats session inspect|input|attach --home /absolute/home [--text-file path] [--json]"), { code: "E_BAD_ARGS" });
2850
+ if (JSON_MODE) jsonOk(result); else console.log(JSON.stringify(result, null, 2));
2851
+ } catch (e) { cmdFail(e.code || "E_SESSION_FAILED", e.message); }
2852
+ }
2853
+
2798
2854
  async function paneCmd() {
2799
2855
  die("`oats pane` has been retired — the OATS Desktop app (packages/desktop) is the control panel now.");
2800
2856
  }
2801
2857
 
2802
2858
  function createCmd() {
2859
+ const yolo = yoloFlag();
2803
2860
  const name = args[1];
2804
- if (!name || name.startsWith("--")) die("usage: oats create <name> [--local] [--description <d>] [--type <agent-type>] [--repo <r>] [--work worktree|checkout|attached|workspace] [--runtime pi|claude] [--model <m>] [--instructions-file <f>]");
2861
+ if (!name || name.startsWith("--")) die("usage: oats create <name> [--local] [--description <d>] [--type <agent-type>] [--repo <r>] [--work worktree|checkout|attached|workspace] [--runtime pi|claude|codex] [--model <m>] [--yolo|--no-yolo] [--instructions-file <f>]");
2805
2862
  const local = args.includes("--local");
2806
2863
  const startDir = dirFlag();
2807
2864
  // `create` BOOTSTRAPS a deployment: with no agents/ or local-agents/ yet,
@@ -2819,7 +2876,7 @@ function createCmd() {
2819
2876
  const instrFile = flag("instructions-file");
2820
2877
  const r = coreCreateAgent(root, {
2821
2878
  name, local, description: flag("description"), type: flag("type"), repo: flag("repo") || defaultRepo(process.cwd()),
2822
- work: flag("work"), runtime: flag("runtime"), model: flag("model"),
2879
+ work: flag("work"), runtime: flag("runtime"), model: flag("model"), yolo,
2823
2880
  instructions: instrFile ? readFileSync(instrFile, "utf8") : undefined,
2824
2881
  });
2825
2882
  if (args.includes("--json")) { console.log(JSON.stringify({ ...r, ...(bootstrapped ? { agentsRoot: root } : {}) }, null, 2)); return; }
@@ -3067,7 +3124,13 @@ function versionCmd() {
3067
3124
  if (JSON_MODE) {
3068
3125
  // EXACT Desktop API v1 probe payload — one JSON object, nothing else on
3069
3126
  // stdout. Desktop accepts desktopApi === 1 and a compatible semver range.
3070
- console.log(JSON.stringify({ schemaVersion: 1, name: "@awebai/oats", version: OATS_VERSION, desktopApi: 1 }));
3127
+ // `remote`: this kernel's remote-side surface: the commands it routes to
3128
+ // a registered server with --server, plus `roster` (the local command
3129
+ // over registrations and saved routes); a Desktop gates its remote path
3130
+ // on it (an older CLI without the surface must fail closed with a
3131
+ // reason, not an argument error). `features`: kernel abilities a peer
3132
+ // must see before relying on them (retire-home: retire --home).
3133
+ console.log(JSON.stringify({ schemaVersion: 1, name: "@awebai/oats", version: OATS_VERSION, desktopApi: 1, runtimes: ["pi", "claude", "codex"], sessionBackends: ["tmux", "herdr"], launchOptions: ["yolo"], remote: ["spawn", "retire", "status", "session", "roster", "harvest"], features: ["retire-home"] }));
3071
3134
  return;
3072
3135
  }
3073
3136
  console.log(`@awebai/oats ${OATS_VERSION} (desktop API v1)`);
@@ -3106,6 +3169,201 @@ async function experimentalCmd() {
3106
3169
  await import(url);
3107
3170
  }
3108
3171
 
3172
+ // ---------- servers: registry and remote routing (docs/execution-targets.md) ----------
3173
+ /** `oats server add|list|remove|check`. A registration is where and how:
3174
+ * an OpenSSH host alias, the remote workspace, the remote oats path. Keys
3175
+ * and passwords never enter it; ssh owns those. */
3176
+ function serverCmd() {
3177
+ const bail = (code, msg) => (JSON_MODE ? jsonFail(code, msg) : die(msg));
3178
+ const sub = args[1];
3179
+ const usage = "usage: oats server add <id> --ssh <host-alias> --workspace </abs/path> [--oats <path>] [--herdr <path>] [--path <dir:dir>] [--label <text>] [--replace] | list | remove <id> | check <id> | roster [--server <id>] | forget <id> --instance <name> [--json]";
3180
+ if (!["add", "list", "remove", "check", "roster", "forget"].includes(sub)) bail("E_USAGE", usage);
3181
+ if (sub === "forget") {
3182
+ // A saved route whose remote instance is gone can be dropped only by
3183
+ // the operator: nothing routed can do it, and the changed-registration
3184
+ // guard counts it until then.
3185
+ const id = args[2];
3186
+ const inst = flag("instance");
3187
+ if (!id || id.startsWith("--") || !inst || inst === true) bail("E_USAGE", "usage: oats server forget <id> --instance <name> [--json]");
3188
+ let snap;
3189
+ try { snap = forgetSnapshot(id, inst); } catch (e) { bail(e.code || "E_SNAPSHOT_UNKNOWN", e.message); }
3190
+ if (JSON_MODE) { jsonOk({ server: id, instance: inst, home: snap.home, target: snap.target, forgotten: true }); return; }
3191
+ console.log(`Forgot the saved route of ${inst} through ${id} (${snap.target?.sshHost}:${snap.home}).`);
3192
+ console.log(` if that home still exists on the host it is no longer managed from here: retire it there with oats retire ${inst} --home ${snap.home}`);
3193
+ return;
3194
+ }
3195
+ if (sub === "roster") {
3196
+ // The remote roster for the Desktop and operators: grouped by saved route
3197
+ // target, one bounded status pull per target, saved routes as the action
3198
+ // authority. --server narrows to one registry id.
3199
+ const only = flag("server") === true ? bail("E_BAD_ARGS", "--server needs a registered server id") : flag("server");
3200
+ const ms = (name) => { const v = flag(name); if (v === undefined) return undefined; const n = Number(v); if (!Number.isInteger(n) || n < 1000) bail("E_BAD_ARGS", `--${name} takes whole milliseconds, at least 1000`); return n; };
3201
+ let out;
3202
+ try { out = rosterGroups({ server: only, io: { budgetMs: ms("budget"), perTargetTimeoutMs: ms("per-target") } }); } catch (e) { bail(e.code || "E_SERVERS_UNREADABLE", e.message); }
3203
+ if (JSON_MODE) { jsonOk(out); return; }
3204
+ if (!out.groups.length) { console.log("no remote groups: no registrations and no saved routes"); return; }
3205
+ for (const g of out.groups) {
3206
+ console.log(` ${g.server}${g.label && g.label !== g.server ? ` ${g.label}` : ""} [${g.id}] ssh ${g.target.sshHost} workspace ${g.target.workspace}${g.registrationPresent ? "" : " (registration removed or changed; saved routes only)"}`);
3207
+ console.log(g.probe?.ok ? ` reachable, ${g.souls.length} soul(s)` : ` UNREACHABLE: ${g.probe?.error?.message || "?"}`);
3208
+ for (const i of g.instances) console.log(` • ${i.instance} ${i.missingRemotely ? "GONE on the host (saved route is stale: oats server forget)" : i.running === true ? "RUNNING" : i.running === false ? "idle" : "unknown"}${i.retirePending ? " RETIRING" : ""}${i.rollbackIncomplete ? " QUARANTINED" : ""}${i.savedRoute ? "" : " (observed only, no saved route)"}${i.runtimeError ? ` ${i.runtimeError}` : ""}`);
3209
+ for (const f of g.retireFailures || []) console.log(` ! deferred retirement of ${f.instance} (${f.agent}) FAILED there${f.error?.message ? `: ${f.error.message}` : ""}${f.retry ? ` — retry on the host: ${f.retry}` : ""}`);
3210
+ }
3211
+ console.log(` bounds: ${out.bounds.perTargetTimeoutMs} ms per target within ${out.bounds.budgetMs} ms, ${out.bounds.elapsedMs} ms used${out.bounds.skipped ? `, ${out.bounds.skipped} target(s) not reached` : ""}`);
3212
+ return;
3213
+ }
3214
+ let servers;
3215
+ try { servers = readServers(); } catch (e) { bail(e.code || "E_SERVERS_UNREADABLE", e.message); }
3216
+ if (sub === "list") {
3217
+ const rows = Object.entries(servers).map(([id, s]) => ({ id, ...s, target: targetOf({ id, ...s }), snapshots: listSnapshots(id).length }));
3218
+ if (JSON_MODE) { jsonOk({ file: SERVERS_FILE(), servers: rows }); return; }
3219
+ if (!rows.length) { console.log(`no servers registered (${shortPath(SERVERS_FILE())}) — add one with \`oats server add <id> --ssh <alias> --workspace </path>\``); return; }
3220
+ for (const r of rows) console.log(` ${r.id}${r.label ? ` ${r.label}` : ""}\n ssh ${r.sshHost} workspace ${r.workspace} oats ${r.target.oatsPath}${r.target.herdrPath ? ` herdr ${r.target.herdrPath}` : ""}${r.snapshots ? ` (${r.snapshots} remote instance${r.snapshots === 1 ? "" : "s"} spawned from here)` : ""}`);
3221
+ return;
3222
+ }
3223
+ const id = args[2];
3224
+ if (!id || id.startsWith("--")) bail("E_USAGE", usage);
3225
+ if (sub === "add") {
3226
+ const val = (name) => { const v = flag(name); return v === true ? bail("E_BAD_ARGS", `--${name} needs a value`) : v; };
3227
+ const entry = { sshHost: val("ssh"), workspace: val("workspace") };
3228
+ for (const [k, f] of [["oatsPath", "oats"], ["herdrPath", "herdr"], ["path", "path"], ["label", "label"]]) { const v = val(f); if (v !== undefined) entry[k] = v; }
3229
+ if (!entry.sshHost || !entry.workspace) bail("E_USAGE", usage);
3230
+ try { validateServer(id, entry); } catch (e) { bail(e.code, e.message); }
3231
+ if (servers[id] && !args.includes("--replace")) bail("E_SERVER_EXISTS", `server ${id} is already registered (pass --replace to overwrite; existing remote instances keep the route they were spawned with)`);
3232
+ servers[id] = entry;
3233
+ writeServers(servers);
3234
+ if (JSON_MODE) { jsonOk({ id, ...entry, file: SERVERS_FILE() }); return; }
3235
+ console.log(`Registered server ${id} → ssh ${entry.sshHost}, workspace ${entry.workspace} (${shortPath(SERVERS_FILE())}). Verify it with \`oats server check ${id}\`.`);
3236
+ return;
3237
+ }
3238
+ if (sub === "remove") {
3239
+ if (!servers[id]) bail("E_SERVER_UNKNOWN", `no server registered as ${id}`);
3240
+ const snaps = listSnapshots(id);
3241
+ delete servers[id];
3242
+ writeServers(servers);
3243
+ if (JSON_MODE) { jsonOk({ removed: id, remoteInstancesStillTracked: snaps.map((s) => s.instance) }); return; }
3244
+ console.log(`Removed server ${id}${snaps.length ? ` — ${snaps.length} remote instance(s) spawned from it keep their snapshots and can still be retired with --server ${id}` : ""}`);
3245
+ return;
3246
+ }
3247
+ // check: reachability and compatibility, no mutation
3248
+ let server; try { server = getServer(id); } catch (e) { bail(e.code, e.message); }
3249
+ const target = targetOf(server);
3250
+ try {
3251
+ const remote = checkRemote(target);
3252
+ const status = routeCommand(id, "status", [], { server });
3253
+ const agents = status.envelope.ok ? (status.envelope.result.agents || []).length : undefined;
3254
+ if (JSON_MODE) { jsonOk({ id, target, remote, workspaceReachable: !!status.envelope.ok, agents, error: status.envelope.ok ? undefined : status.envelope.error }); return; }
3255
+ console.log(`${id}: ssh ${target.sshHost} ok, remote oats ${remote.version} (envelope v${remote.schemaVersion})`);
3256
+ console.log(status.envelope.ok ? ` workspace ${target.workspace}: ${agents} agent(s)` : ` workspace ${target.workspace}: ${status.envelope.error?.message || "not usable"}`);
3257
+ if (!status.envelope.ok) process.exit(1);
3258
+ } catch (e) { bail(e.code || "E_SSH", e.message); }
3259
+ }
3260
+
3261
+ /** `oats <spawn|retire|status> --server <id> ...`: run the command on the
3262
+ * registered server's installed oats, same arguments, same envelope. The
3263
+ * local side only routes and keeps the route snapshot per remote instance. */
3264
+ function serverRouteCmd() {
3265
+ const bail = (code, msg) => (JSON_MODE ? jsonFail(code, msg) : die(msg));
3266
+ const id = flag("server");
3267
+ if (id === true || !id) bail("E_BAD_ARGS", "--server needs a registered server id (oats server list)");
3268
+ if (flag("dir") !== undefined || args.some((a) => a.startsWith("--dir="))) bail("E_BAD_ARGS", "--dir cannot be combined with --server: the remote workspace comes from the server registration");
3269
+ // Interactive viewer: `oats session attach --server <id> --instance <name>`
3270
+ // (or --home </abs/remote/home>) runs the execution host's own attach
3271
+ // through an ssh PTY with this terminal's stdio; nothing is captured.
3272
+ if (cmd === "okf") {
3273
+ // `oats okf harvest --server <id> --instance <name>`: the package's
3274
+ // harvest command run in the instance's SAVED home on the host.
3275
+ if (args[1] !== "harvest") bail("E_USAGE", "--server routes `okf harvest` only among the okf commands");
3276
+ const inst = flag("instance");
3277
+ if (!inst || inst === true) bail("E_BAD_ARGS", "okf harvest --server needs --instance <name> (spawned from here)");
3278
+ let routed;
3279
+ try { routed = routeCommand(id, "harvest", [inst]); } catch (e) { bail(e.code || "E_SSH", e.message); }
3280
+ if (routed.stderr?.trim()) process.stderr.write(routed.stderr.endsWith("\n") ? routed.stderr : routed.stderr + "\n");
3281
+ if (JSON_MODE) { console.log(JSON.stringify(routed.envelope, null, 2)); if (!routed.envelope.ok) process.exit(1); return; }
3282
+ if (!routed.envelope.ok) die(`${id}: ${routed.envelope.error?.message || "harvest failed"} (${routed.envelope.error?.code || "E_REMOTE"})`);
3283
+ const hr = routed.envelope.result;
3284
+ console.log(`Harvest on ${id} for ${inst}: ${hr.harvest}${hr.reason ? ` (${hr.reason})` : ""}${hr.instance && hr.harvest === "spawned" ? ` — harvester ${hr.instance}` : ""}`);
3285
+ if (hr.instance && hr.instance !== inst) console.log(` the harvester ${hr.instance} runs on ${id}; retire it there when it is done, or let it self-retire`);
3286
+ return;
3287
+ }
3288
+ if (cmd === "session") {
3289
+ const addr = { instance: flag("instance") === true ? undefined : flag("instance"), home: flag("home") === true ? undefined : flag("home") };
3290
+ if (args[1] === "inspect") {
3291
+ // Desktop preflight before a remote attach: the execution host's own
3292
+ // inspect, relayed as its envelope; a failure is a failure, nonzero.
3293
+ let out;
3294
+ try { out = inspectRemote(id, addr); } catch (e) { bail(e.code || "E_SSH", e.message); }
3295
+ if (out.stderr?.trim()) process.stderr.write(out.stderr.endsWith("\n") ? out.stderr : out.stderr + "\n");
3296
+ if (JSON_MODE) { console.log(JSON.stringify(out.envelope, null, 2)); if (!out.envelope.ok) process.exit(1); return; }
3297
+ if (!out.envelope.ok) die(`${id}: ${out.envelope.error?.message || "inspect failed"} (${out.envelope.error?.code || "E_REMOTE"})`);
3298
+ const r = out.envelope.result;
3299
+ console.log(`${r.instance || r.home} on ${id}: ${r.present ? `present, ${r.state || "unknown"}` : "not present"}${r.backend ? ` (${r.backend})` : ""}`);
3300
+ return;
3301
+ }
3302
+ if (args[1] !== "attach") bail("E_USAGE", "--server routes `session inspect` and `session attach`; input runs on the execution host (the wake broker calls it there)");
3303
+ let route;
3304
+ try { route = attachArgv(id, addr, { skipVersionCheck: args.includes("--print") }); }
3305
+ catch (e) { bail(e.code || "E_BAD_ARGS", e.message); }
3306
+ if (args.includes("--print")) { console.log(route.argv.map(shellQuote).join(" ")); return; }
3307
+ const r = spawnSyncProc(route.argv[0], route.argv.slice(1), { stdio: "inherit" });
3308
+ process.exit(r.status ?? 1);
3309
+ }
3310
+ // Everything after the command word travels, minus the routing flags; a
3311
+ // local --task-file is read here and travels as --task text, since the
3312
+ // remote cannot read this machine's files.
3313
+ const rest = [];
3314
+ for (let i = 1; i < args.length; i++) {
3315
+ const a = args[i];
3316
+ if (a === "--server") { i++; continue; }
3317
+ if (a === "--json") continue;
3318
+ if (a === "--task-file") {
3319
+ const f = args[++i];
3320
+ if (!f || f.startsWith("--")) bail("E_BAD_ARGS", "--task-file needs a path");
3321
+ if (!existsSync(f)) bail("E_BAD_ARGS", `task file not found: ${f}`);
3322
+ rest.push("--task", readFileSync(f, "utf8"));
3323
+ continue;
3324
+ }
3325
+ rest.push(a);
3326
+ }
3327
+ let routed;
3328
+ try { routed = routeCommand(id, cmd, rest); }
3329
+ catch (e) { bail(e.code || "E_SSH", e.message); }
3330
+ const { envelope, stderr } = routed;
3331
+ if (stderr && stderr.trim()) process.stderr.write(stderr.endsWith("\n") ? stderr : stderr + "\n");
3332
+ if (JSON_MODE) { console.log(JSON.stringify(envelope, null, 2)); if (!envelope.ok || envelope.result?.rollbackIncomplete) process.exit(1); return; }
3333
+ if (!envelope.ok && !(cmd === "retire" && envelope.result)) die(`${id}: ${envelope.error?.message || "remote command failed"} (${envelope.error?.code || "E_REMOTE"})`);
3334
+ const r = envelope.result;
3335
+ const target = r.target || {};
3336
+ if (cmd === "spawn") {
3337
+ console.log(`Spawned ${r.instance} on ${id} (${r.work}${r.branch ? `, branch ${r.branch}` : ""})${r.launched ? ` — tmux window "${r.tmux?.window}" on ${r.target.sshHost}` : " — not launched"}`);
3338
+ console.log(` remote home: ${r.home}`);
3339
+ console.log(` route snapshot: ${r.snapshot ? shortPath(r.snapshot) : "none (see the warning)"}`);
3340
+ for (const w of r.warnings || []) console.log(` WARNING: ${w}`);
3341
+ console.log(` attach: ssh -t ${r.target.sshHost} tmux attach -t ${r.tmux?.session || "oats"}`);
3342
+ } else if (cmd === "retire") {
3343
+ // Everything the local retireCmd tells the operator, for a remote home
3344
+ // they cannot see: forced-incomplete state now theirs to remove by hand
3345
+ // there, work preserved there and where, and incomplete cleanup.
3346
+ if (r.forcedIncomplete) {
3347
+ console.error(`Removed ${r.retired} on ${id} under --force with cleanup INCOMPLETE — this external state was NOT cleaned up and is now yours to remove by hand on ${target.sshHost}:`);
3348
+ for (const f of r.forcedIncomplete) console.error(` ${f}`);
3349
+ }
3350
+ console.log(`Retired ${r.retired} on ${id}${r.deferred ? " (deferred completion scheduled there)" : ""}${r.rollbackIncomplete ? " — cleanup INCOMPLETE on the server, home retained there" : ""}`);
3351
+ for (const recovery of r.workRecoveries || (r.workRecovery ? [r.workRecovery] : [])) {
3352
+ console.log(`Work that was not committed has been preserved on ${target.sshHost}: ${(recovery.classes || []).join(", ")}`);
3353
+ console.log(` ${recovery.path}`);
3354
+ }
3355
+ if (r.rollbackIncomplete) { for (const f of r.rollbackIncomplete) console.error(` ${f}`); console.error(`Fix the cause there and re-run \`oats retire ${r.retired} --server ${id}\`.`); process.exit(1); }
3356
+ } else {
3357
+ console.log(`oats status — server ${id} (ssh ${r.target.sshHost}, workspace ${r.target.workspace})\n`);
3358
+ for (const a of r.agents || []) {
3359
+ console.log(` ${a.name} [work: ${a.work || "checkout"}, repo: ${a.repo || "?"}]`);
3360
+ for (const i of a.instances || []) console.log(` • ${i.instance} ${i.retirePending ? "RETIRING" : i.running ? "RUNNING" : "idle"}`);
3361
+ }
3362
+ const snaps = r.snapshots || [];
3363
+ if (snaps.length) console.log(`\n spawned from this machine: ${snaps.map((s) => s.instance).join(", ")}`);
3364
+ }
3365
+ }
3366
+
3109
3367
  // ---------- main ----------
3110
3368
  // Typed config-shape failures are DEPLOYMENT state the operator can fix, not
3111
3369
  // kernel bugs: an unsafe mapping key anywhere in the visible config chain is
@@ -3121,7 +3379,9 @@ async function experimentalCmd() {
3121
3379
  // blame` pointing at the commit that last changed each command.
3122
3380
  const TYPED_CLI_FAILURES = new Set(["unsafe-config-key", "unsafe-config-value"]);
3123
3381
  try {
3124
- if (cmd === "doctor") {
3382
+ if (flag("server") !== undefined && ["spawn", "retire", "status", "session", "okf"].includes(cmd)) serverRouteCmd();
3383
+ else if (cmd === "server") serverCmd();
3384
+ else if (cmd === "doctor") {
3125
3385
  const doctorDir = args[1] && !args[1].startsWith("--") ? args[1] : undefined;
3126
3386
  args.includes("--json") ? doctorJson(doctorDir) : doctor(doctorDir);
3127
3387
  }
@@ -3142,6 +3402,7 @@ else if (cmd === "pane") await paneCmd();
3142
3402
  else if (cmd === "version" || cmd === "--version" || cmd === "-v") versionCmd();
3143
3403
  // Same rule as the inner catch: a typed CLI failure surfaces with its own code
3144
3404
  // through the shared boundary, never re-badged as a spawn-mechanism failure.
3405
+ else if (cmd === "session") await sessionCmd();
3145
3406
  else if (cmd === "spawn") { try { spawnCmd(); } catch (e) { if (TYPED_CLI_FAILURES.has(e?.code)) throw e; if (JSON_MODE) jsonFail("E_SPAWN_FAILED", e.message || e); throw e; } }
3146
3407
  else if (cmd === "retire") retireCmd();
3147
3408
  else if (cmd === "create") createCmd();
@@ -3163,24 +3424,46 @@ Usage:
3163
3424
  Desktop CLI API v1 probe payload
3164
3425
  oats status [--json] agents, souls, running instances
3165
3426
  oats status --team [--json] whole-team roster across the team scope's repos
3427
+ oats server add <id> --ssh <alias> register another machine's OATS (OpenSSH alias,
3428
+ --workspace </abs/path> [--oats <p>] remote workspace, remote oats path; no keys stored;
3429
+ [--path <dir:dir>] --path = dirs prepended to the remote PATH, e.g. ~/.local/bin)
3430
+ oats server list|remove <id>|check <id> registry; check = reachability + version, no mutation
3431
+ oats spawn|retire|status ... --server <id> run that command on the server's installed oats
3432
+ (same flags, same envelope; the saved route per
3433
+ remote instance lives under ~/.oats/remote/)
3434
+ oats server roster [--server <id>] remote roster grouped by server and saved route
3435
+ [--budget <ms>] [--per-target <ms>] target: one status pull per group within a total
3436
+ [--json] budget (45 s, 20 s per target); saved routes are
3437
+ the authority for actions
3438
+ oats server forget <id> --instance <name> drop a saved route whose remote instance is gone
3439
+ (the roster shows it as missingRemotely)
3440
+ oats retire <instance> --home <path> retire exactly that home when two agents own an
3441
+ instance of the same name (else refused)
3442
+ oats okf harvest --server <id> run the knowledge harvest in a remote instance's
3443
+ --instance <name> [--json] saved home on its host
3444
+ oats session inspect|attach --server <id> inspect (envelope) or attach a viewer (ssh PTY) for a
3445
+ --instance <name> | --home <abs> remote instance over its saved route (--print shows
3446
+ attach); the server needs oats 0.22.2 or later
3166
3447
  oats create <name> [--local] create an agent soul; --local = full
3167
3448
  [--description <d>] [--repo <r>] soul under local-agents/ (uncommitted,
3168
- [--work <mode>] [--runtime pi|claude] gitignored; same memory + lifecycle)
3169
- [--model <m>] [--instructions-file <f>]
3170
- oats spawn <agent> [--task <text>] spawn an instance (tmux; --no-launch
3449
+ [--work <mode>] [--runtime pi|claude|codex] gitignored; same memory + lifecycle)
3450
+ [--model <m>] [--yolo|--no-yolo] [--instructions-file <f>]
3451
+ oats session inspect|input|attach --home <absolute-home> [--text-file <path>] [--json]
3452
+ oats spawn <agent> [--task <text>] spawn an instance (tmux/Herdr; --no-launch
3171
3453
  [--purpose <slug>] [--repo <r>] = scaffold only); --instructions-file/
3172
3454
  [--parent <instance>] --def-file creates a local agent;
3173
3455
  [--relation child|sibling|parent|unrelated] --relation + --relative-to anchor the
3174
3456
  [--relative-to <instance>] new instance to an existing one; --parent X
3175
3457
  [--relative-root <agents-root>] disambiguates same-named team anchors
3176
3458
  [--work worktree|checkout|attached|workspace] = sugar for --relative-to X --relation
3177
- [--work-dir <owner-work>] [--runtime pi|claude] [--model <m>] [--branch <b>] child (default: unrelated, top-level)
3459
+ [--work-dir <owner-work>] [--runtime pi|claude|codex] [--backend tmux|herdr] [--herdr-socket <path>] [--yolo|--no-yolo] [--model <m>] [--branch <b>] child (default: unrelated, top-level)
3178
3460
  [--instructions-file <f>|--def-file <f>] [--no-launch] [--json]
3179
3461
  with team: declared, unknown local souls
3180
3462
  resolve across the team scope's repos
3181
3463
  oats retire <instance> [--force] retire an instance (window, hooks,
3182
3464
  [--self] [--delete-branch] worktree, home); --self = retire the
3183
- [--keep-dir] [--json] CALLING instance (delayed window kill)
3465
+ [--keep-dir] [--json] CALLING instance: the window dies, then
3466
+ a detached external retirement runs
3184
3467
  oats doctor [dir] [--soul <name>] [--json] resolved targets, trust, requirements;
3185
3468
  --soul shows final composed AGENTS.md
3186
3469
  oats update [--check] [--yes] check npm for a newer kernel+pi bridge and