@awebai/oats 0.30.3 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/oats.mjs CHANGED
@@ -23,6 +23,7 @@ import { homedir, tmpdir } from "node:os";
23
23
  import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
24
24
  import { fileURLToPath } from "node:url";
25
25
  import { runtimeNameWarning, noteRuntimeName } from "../lib/deprecation.mjs";
26
+ import { herdrSettingRemoved } from "../lib/errors.mjs";
26
27
  import {
27
28
  LAYERS, OATS_VERSION, manifestOperations, upgradeHomeMeta,
28
29
  capabilityManifests, capabilityTrust, capabilityExecutablePath,
@@ -78,7 +79,7 @@ const KERNEL_COMMANDS = new Set(["automations", "trigger", "capture", "capabilit
78
79
  /** Commands whose argv another parser reads (packages/record and packages/experimental parse process.argv). */
79
80
  const OWN_ARGV_COMMANDS = new Set(["capture", "recall", "setup", "experimental"]);
80
81
  /** Commands `--server <id>` runs on a registered server. */
81
- const ROUTED_COMMANDS = new Set(["spawn", "retire", "status", "session", "okf", "schedule", "inspect", "operation", "launch-config"]);
82
+ const ROUTED_COMMANDS = new Set(["spawn", "retire", "status", "session", "okf", "schedule", "inspect", "operation", "launch-config", "readiness", "instance"]);
82
83
  const flag = (name) => {
83
84
  const i = args.indexOf(`--${name}`);
84
85
  return i >= 0 ? (args[i + 1] && !args[i + 1].startsWith("--") ? args[i + 1] : true) : undefined;
@@ -92,7 +93,7 @@ function valueFlag(name) {
92
93
  if (value === true) cmdFail("E_BAD_ARGS", `--${name} needs a value`);
93
94
  return value;
94
95
  }
95
- const die = (msg) => { console.error(`oats: ${msg}`); process.exit(1); };
96
+ const die = (msg, exit = 1) => { console.error(`oats: ${msg}`); process.exit(exit); };
96
97
  /** A command's harness: --harness, or --runtime, its pre-0.27 name (the released okf worker and
97
98
  * a 0.26-era Desktop pass it) — read either, with the deprecation warning. Both, disagreeing,
98
99
  * are refused. `get` reads one flag (the command's own reader where it has one). */
@@ -139,7 +140,7 @@ const withLocalWarnings = (envelope) => {
139
140
  const sources = [...new Set([...(Array.isArray(same.sources) ? same.sources : []), ...mine.sources])];
140
141
  return { ...envelope, warnings: theirs.map((w) => (w === same ? { ...mine, sources, message: mine.message.replace(/\(.*\)/, `(${sources.join("; ")})`) } : w)) };
141
142
  };
142
- const jsonFail = (code, message, details) => { console.log(JSON.stringify({ schemaVersion: 1, ok: false, error: { code, message: String(message), ...(details !== undefined ? { details } : {}) }, ...envelopeWarnings() })); process.exit(1); };
143
+ const jsonFail = (code, message, details, exit = 1) => { console.log(JSON.stringify({ schemaVersion: 1, ok: false, error: { code, message: String(message), ...(details !== undefined ? { details } : {}) }, ...envelopeWarnings() })); process.exit(exit); };
143
144
  const jsonOk = (result) => { console.log(JSON.stringify({ schemaVersion: 1, ok: true, result, ...envelopeWarnings() })); };
144
145
  // Text mode (or a JSON answer printed before the read): the warning goes to stderr, never stdout.
145
146
  process.on("exit", () => { const w = runtimeNameWarning(); if (w && !warningDelivered) process.stderr.write(`oats: warning: ${w.message}\n`); });
@@ -258,7 +259,7 @@ const INSPECT_TEXT_CAP = 256 * 1024;
258
259
  /** The agents root a home belongs to, from its path alone:
259
260
  * <root>/<agent>/instances/<instance>. */
260
261
  function agentsRootOfHome(home) { return dirname(dirname(dirname(home))); }
261
- const SOUL_FIELDS = ["harness", "model", "yolo", "backend", "description", "launch-config"];
262
+ const SOUL_FIELDS = ["harness", "model", "yolo", "description", "launch-config"];
262
263
  const realOrResolved = (p) => { try { return realpathSync(p); } catch { return resolve(p); } };
263
264
  /** Every soul of a scope: the persistent souls of every agents root in
264
265
  * scope, plus packaged souls (read-only). One enumeration for inspect and
@@ -348,7 +349,7 @@ function soulEntry(soul, root, { capability } = {}) {
348
349
  declarationProblems: declared.problems,
349
350
  name: soul.name, kind: packaged ? "capability" : (soul.kind || "persistent"), capability: capability || null,
350
351
  type: soul.type ?? null, description: soul.description ?? null, repo: soul.repo ?? null, work: soul.work || "checkout",
351
- harness: soul.harness || "pi", model: soul.model ?? null, yolo: soul.yolo === true || soul.yolo === "true" ? true : soul.yolo === false || soul.yolo === "false" ? false : null, launchConfig: soul["launch-config"] ?? null, backend: soul.backend ?? null,
352
+ harness: soul.harness || "pi", model: soul.model ?? null, yolo: soul.yolo === true || soul.yolo === "true" ? true : soul.yolo === false || soul.yolo === "false" ? false : null, launchConfig: soul["launch-config"] ?? null,
352
353
  agentsRoot: root, dir: packaged ? soulDir : dir, soulFile: join(soulDir, "soul.yaml"), instructionsFile: join(soulDir, "AGENTS.md"),
353
354
  editable: packaged
354
355
  ? { fields: [], instructions: false, reason: `packaged soul from capability ${capability}: edit the package and update it; scoped bindings still apply through oats use` }
@@ -1789,7 +1790,7 @@ async function status() {
1789
1790
  console.log(` ${a.name} [work: ${a.work || "checkout"}, repo: ${soulRepoLabel(a, ws)}]`);
1790
1791
  if (a.description) console.log(` ${a.description}`);
1791
1792
  for (const i of a.instances) {
1792
- console.log(` • ${i.instance} ${i.retirePending ? "RETIRING" : i.running ? "RUNNING" : "idle"} (branch ${i.branch || "?"}, ${i.work || "?"})`);
1793
+ console.log(` • ${i.instance} ${i.retirePending ? "RETIRING" : livenessWord(i)} (branch ${i.branch || "?"}, ${i.work || "?"})${i.runtimeError ? ` ${i.runtimeError}` : ""}`);
1793
1794
  const key = i.home ?? `${a.name}/${i.instance}`;
1794
1795
  if (i.identity) console.log(` identity: ${servedIdentityLine(i.identity)}`);
1795
1796
  // The kernel the home's plain `oats` runs (its last launch's), when it is not this one.
@@ -1806,10 +1807,12 @@ async function status() {
1806
1807
  }
1807
1808
  }
1808
1809
 
1810
+ /** A status row's liveness in text: `running` null (a server that cannot be read, a Herdr home) is unknown, never idle. */
1811
+ const livenessWord = (i) => i.running === true ? "RUNNING" : i.running === false ? "idle" : "unknown";
1809
1812
  /** The flags `oats spawn` reads: those taking a value, and switches. `--provider` takes two words.
1810
1813
  * `--instance` is refused by a local spawn (with its replacement) but still travels to an older
1811
1814
  * host through `--server`, whose route reads it. */
1812
- const SPAWN_VALUE_FLAGS = new Set(["agents-root", "backend", "base", "branch", "dir", "expect-decision", "harness", "herdr-socket", "idempotency-key", "instance", "launch-config", "model", "name", "parent", "purpose", "relation", "relative-root", "relative-to", "repo", "runtime", "task", "task-file", "trigger-event", "wake-cron", "wake-every", "wake-file", "wake-json", "wake-message", "wake-message-file", "wake-tz", "work", "work-dir"]);
1815
+ const SPAWN_VALUE_FLAGS = new Set(["agents-root", "backend", "base", "branch", "dir", "expect-decision", "harness", "idempotency-key", "instance", "launch-config", "model", "name", "parent", "purpose", "relation", "relative-root", "relative-to", "repo", "runtime", "task", "task-file", "trigger-event", "wake-cron", "wake-every", "wake-file", "wake-json", "wake-message", "wake-message-file", "wake-tz", "work", "work-dir"]);
1813
1816
  const SPAWN_SWITCHES = new Set(["allow-child-spawns", "json", "no-child-spawns", "no-launch", "no-yolo", "preview", "yolo"]);
1814
1817
  /** Why `argv` (after `spawn`, the soul first) is not a spawn, or undefined: a positional after the
1815
1818
  * soul or a flag spawn does not read is never ignored. A value flag consumes its value exactly as
@@ -1831,13 +1834,20 @@ function spawnArgvProblem(argv) {
1831
1834
  }
1832
1835
  return undefined;
1833
1836
  }
1837
+ /** The Herdr spawn flags (removed in 0.31.0), named for the refusal; undefined when none is given. */
1838
+ function herdrSpawnFlag() {
1839
+ if (flag("backend") === "herdr") return "--backend herdr";
1840
+ if (flag("herdr-socket") !== undefined) return "--herdr-socket";
1841
+ return undefined;
1842
+ }
1834
1843
  async function spawnCmd() {
1835
1844
  // JSON mode: contract envelope, stable error codes, stderr-only progress.
1836
1845
  const bail = (code, msg, details) => (JSON_MODE ? jsonFail(code, msg, details) : die(msg));
1837
1846
  const note = (msg) => (JSON_MODE ? console.error(msg) : console.log(msg));
1838
1847
  const yolo = yoloFlag();
1839
- const backend = valueFlag("backend"), herdrSocket = valueFlag("herdr-socket");
1840
- if (backend !== undefined && !["tmux", "herdr"].includes(backend)) bail("E_BAD_ARGS", "--backend must be tmux or herdr");
1848
+ { const herdr = herdrSpawnFlag(); if (herdr) { const e = herdrSettingRemoved(`${herdr} was given`); bail(e.code, e.message); } }
1849
+ const backend = valueFlag("backend");
1850
+ if (backend !== undefined && backend !== "tmux") bail("E_BAD_ARGS", "--backend must be tmux");
1841
1851
  const requestedWork = valueFlag("work");
1842
1852
  const workDir = valueFlag("work-dir"), branch = valueFlag("branch"), repo = valueFlag("repo");
1843
1853
  const checkDirectoryOptions = (work) => {
@@ -1845,7 +1855,7 @@ async function spawnCmd() {
1845
1855
  };
1846
1856
  checkDirectoryOptions(requestedWork); // before anything is resolved or written
1847
1857
  const name = args[1];
1848
- if (!name || name.startsWith("--")) bail("E_USAGE", "usage: oats spawn <agent> [--task <text>|--task-file <f>] [--purpose <slug>|--name <slug>] [--preview] [--base <ref>] [--model <id>|@native-default] [--allow-child-spawns|--no-child-spawns] [--relation child|sibling|parent|unrelated --relative-to <instance> [--relative-root <agents-root>]] [--parent <instance>] [--repo <r>] [--work worktree|checkout|attached|workspace|directory] [--work-dir <owner-work>] [--harness pi|claude|codex] [--backend tmux|herdr] [--herdr-socket <path>] [--yolo|--no-yolo] [--model <m>] [--branch <b>] [--no-launch] [--json]");
1858
+ if (!name || name.startsWith("--")) bail("E_USAGE", "usage: oats spawn <agent> [--task <text>|--task-file <f>] [--purpose <slug>|--name <slug>] [--preview] [--base <ref>] [--model <id>|@native-default] [--allow-child-spawns|--no-child-spawns] [--relation child|sibling|parent|unrelated --relative-to <instance> [--relative-root <agents-root>]] [--parent <instance>] [--repo <r>] [--work worktree|checkout|attached|workspace|directory] [--work-dir <owner-work>] [--harness pi|claude|codex] [--backend tmux] [--yolo|--no-yolo] [--model <m>] [--branch <b>] [--no-launch] [--json]");
1849
1859
  // Retired boundary flags (maintainer transport ruling): fail LOUDLY before
1850
1860
  // ANY side effect, including root discovery.
1851
1861
  // Local souls (local-agents/) are gone with the workspace model: a soul is a member
@@ -2039,7 +2049,7 @@ async function spawnCmd() {
2039
2049
  // An attached instance's repository is its work tree owner's (derived by the kernel).
2040
2050
  repo: preparedRepo !== undefined ? preparedRepo : ["directory", "attached"].includes(requestedWork || agent.work)
2041
2051
  ? repo : repo || defaultRepo(workspaceOf(root)) || defaultRepo(process.cwd()),
2042
- work: requestedWork, workDir, harness: harnessFlag(), backend, herdrSocket, yolo, model: flag("model"), branch,
2052
+ work: requestedWork, workDir, harness: harnessFlag(), backend, yolo, model: flag("model"), branch,
2043
2053
  launchConfig: valueFlag("launch-config"),
2044
2054
  launch: !args.includes("--no-launch"),
2045
2055
  ...(triggerEvent ? { triggerEvent } : {}),
@@ -2106,11 +2116,10 @@ async function spawnCmd() {
2106
2116
  instance: r.instance, agent: r.agent, home: r.home, work: r.work,
2107
2117
  branch: r.branch || null, launched: r.launched, warnings: r.warnings || [],
2108
2118
  ...(wakeSchedule ? { wakeSchedule } : {}), ...(wakeScheduleError ? { wakeScheduleError } : {}),
2109
- tmux: r.tmux || null, repo: r.repo || null, harness: r.harness || null,
2119
+ tmux: r.tmux || null, backend: "tmux", repo: r.repo || null, harness: r.harness || null,
2110
2120
  model: r.model || null, parent: r.parentInstance || null,
2111
2121
  sibling: r.siblingInstance || null, relation: r.relation || null,
2112
2122
  spawnOrigin: r.spawnOrigin, attach: r.attach,
2113
- ...(r.sessionTarget ? { sessionTarget: r.sessionTarget } : {}),
2114
2123
  ...(r.yolo !== undefined ? { yolo: r.yolo } : {}),
2115
2124
  // K6b/K6c: what bound this spawn, and whether this receipt is a replay of an earlier one.
2116
2125
  ...(r.decision ? { decision: r.decision } : {}), ...(r.replayed !== undefined ? { replayed: r.replayed } : {}),
@@ -2119,7 +2128,7 @@ async function spawnCmd() {
2119
2128
  });
2120
2129
  return;
2121
2130
  }
2122
- 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"}`);
2131
+ console.log(`Spawned ${r.instance} (${r.work}${r.branch ? `, branch ${r.branch}` : ""})${r.launched ? ` — tmux window "${r.tmux.window}"` : " — not launched"}`);
2123
2132
  console.log(` home: ${shortPath(r.home)}`);
2124
2133
  if (wakeSchedule) console.log(` wake: schedule ${wakeSchedule.id} (${wakeSchedule.cron} ${wakeSchedule.tz}), next ${wakeSchedule.nextRun || "disabled"}`);
2125
2134
  if (wakeScheduleError) console.error(` wake: NOT saved — ${wakeScheduleError.message} (the instance is created and launched; add the wake by hand with oats schedule add)`);
@@ -2852,7 +2861,7 @@ function versionCmd() {
2852
2861
  // Phase B: `instance-modules` and `spawn-provider-payload` are advertised only once spawn
2853
2862
  // runs on resolve/materialize (contract §6); a feature the binary does not implement is
2854
2863
  // never listed.
2855
- console.log(JSON.stringify({ schemaVersion: 1, name: "@awebai/oats", version: OATS_VERSION, desktopApi: 1, harnesses: ["pi", "claude", "codex"], sessionBackends: ["tmux", "herdr"], launchOptions: ["yolo"], remote: ["spawn", "retire", "status", "session", "session-start", "session-restart", "launch-config", "roster", "harvest", "schedule", "session-upload", "operations"], features: ["retire-home", "session-start", "session-restart", "launch-config", "schedule", "session-upload", "operations", "instance-git", "instance-git-remote", "souls-declarations", "lifecycle-plans", "retire-retention", "readiness", "spawn-preview", "instance-events", "instance-events-2", "schedule-history", "schedule-read-2", "spawn-preview-2", "spawn-idempotency", "spawn-idempotency-2", "spawn-apply-2", "workspace-v2", "instance-modules", "spawn-provider-payload", "served-identity", "packages-no-approval", "spawn-name", "settings-origins", "team-model-2", "settings-declared", "capabilities-private", "layers-from", "harness", "package-souls", "triggers", "automations", "desktop-facts", "launch-preference", "preview-composed-from"], automationsApi: A.AUTOMATIONS_API, workspaceApi: 2, instanceGitApi: 1, spawnApplyApi: 1, soulsApi: 2, lifecycleApi: 1, readinessApi: 2, spawnPreviewApi: 2, eventsApi: 2, scheduleHistoryApi: 3, scheduleApi: SCHEDULE_API, operationsApi: 2 }));
2864
+ console.log(JSON.stringify({ schemaVersion: 1, name: "@awebai/oats", version: OATS_VERSION, desktopApi: 1, harnesses: ["pi", "claude", "codex"], sessionBackends: ["tmux"], launchOptions: ["yolo"], remote: ["spawn", "retire", "status", "session", "session-start", "session-restart", "launch-config", "roster", "harvest", "schedule", "session-upload", "operations", "readiness", "instance-events", "instance-git", "lifecycle-plans"], features: ["retire-home", "session-start", "session-restart", "launch-config", "schedule", "session-upload", "operations", "instance-git", "instance-git-remote", "souls-declarations", "lifecycle-plans", "retire-retention", "readiness", "spawn-preview", "instance-events", "instance-events-2", "schedule-history", "schedule-read-2", "spawn-preview-2", "spawn-idempotency", "spawn-idempotency-2", "spawn-apply-2", "workspace-v2", "instance-modules", "spawn-provider-payload", "served-identity", "packages-no-approval", "spawn-name", "settings-origins", "team-model-2", "settings-declared", "capabilities-private", "layers-from", "harness", "package-souls", "triggers", "automations", "desktop-facts", "launch-preference", "preview-composed-from"], automationsApi: A.AUTOMATIONS_API, workspaceApi: 2, instanceGitApi: 1, spawnApplyApi: 1, soulsApi: 2, lifecycleApi: 1, readinessApi: 2, spawnPreviewApi: 2, eventsApi: 2, scheduleHistoryApi: 3, scheduleApi: SCHEDULE_API, operationsApi: 2 }));
2856
2865
  return;
2857
2866
  }
2858
2867
  console.log(`@awebai/oats ${OATS_VERSION} (desktop API v1)`);
@@ -2896,9 +2905,9 @@ async function experimentalCmd() {
2896
2905
  * an OpenSSH host alias, the remote workspace, the remote oats path. Keys
2897
2906
  * and passwords never enter it; ssh owns those. */
2898
2907
  function serverCmd() {
2899
- const bail = (code, msg) => (JSON_MODE ? jsonFail(code, msg) : die(msg));
2908
+ const bail = (code, msg, details) => (JSON_MODE ? jsonFail(code, msg, details) : die(msg));
2900
2909
  const sub = args[1];
2901
- 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]";
2910
+ const usage = "usage: oats server add <id> --ssh <host-alias> --workspace </abs/path> [--oats <path>] [--path <dir:dir>] [--label <text>] [--replace] | list | remove <id> | check <id> | roster [--server <id>] | forget <id> --instance <name> [--json]";
2902
2911
  if (!["add", "list", "remove", "check", "roster", "forget"].includes(sub)) bail("E_USAGE", usage);
2903
2912
  if (sub === "forget") {
2904
2913
  // A saved route whose remote instance is gone can be dropped only by
@@ -2939,15 +2948,16 @@ function serverCmd() {
2939
2948
  const rows = Object.entries(servers).map(([id, s]) => ({ id, ...s, target: targetOf({ id, ...s }), snapshots: listSnapshots(id).length }));
2940
2949
  if (JSON_MODE) { jsonOk({ file: SERVERS_FILE(), servers: rows }); return; }
2941
2950
  if (!rows.length) { console.log(`no servers registered (${shortPath(SERVERS_FILE())}) — add one with \`oats server add <id> --ssh <alias> --workspace </path>\``); return; }
2942
- 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)` : ""}`);
2951
+ 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.snapshots ? ` (${r.snapshots} remote instance${r.snapshots === 1 ? "" : "s"} spawned from here)` : ""}`);
2943
2952
  return;
2944
2953
  }
2945
2954
  const id = args[2];
2946
2955
  if (!id || id.startsWith("--")) bail("E_USAGE", usage);
2947
2956
  if (sub === "add") {
2948
2957
  const val = (name) => { const v = flag(name); return v === true ? bail("E_BAD_ARGS", `--${name} needs a value`) : v; };
2958
+ if (flag("herdr") !== undefined) { const e = herdrSettingRemoved("server add --herdr was given"); bail(e.code, e.message); }
2949
2959
  const entry = { sshHost: val("ssh"), workspace: val("workspace") };
2950
- for (const [k, f] of [["oatsPath", "oats"], ["herdrPath", "herdr"], ["path", "path"], ["label", "label"]]) { const v = val(f); if (v !== undefined) entry[k] = v; }
2960
+ for (const [k, f] of [["oatsPath", "oats"], ["path", "path"], ["label", "label"]]) { const v = val(f); if (v !== undefined) entry[k] = v; }
2951
2961
  if (!entry.sshHost || !entry.workspace) bail("E_USAGE", usage);
2952
2962
  try { validateServer(id, entry); } catch (e) { bail(e.code, e.message); }
2953
2963
  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)`);
@@ -2970,27 +2980,30 @@ function serverCmd() {
2970
2980
  let server; try { server = getServer(id); } catch (e) { bail(e.code, e.message); }
2971
2981
  const target = targetOf(server);
2972
2982
  try {
2973
- const remote = checkRemote(target);
2983
+ const remote = checkRemote(target, { serverId: id });
2974
2984
  const status = routeCommand(id, "status", [], { server });
2975
2985
  const agents = status.envelope.ok ? (status.envelope.result.agents || []).length : undefined;
2976
2986
  if (JSON_MODE) { jsonOk({ id, target, remote, workspaceReachable: !!status.envelope.ok, agents, error: status.envelope.ok ? undefined : status.envelope.error }); return; }
2977
2987
  console.log(`${id}: ssh ${target.sshHost} ok, remote oats ${remote.version} (envelope v${remote.schemaVersion})`);
2978
2988
  console.log(status.envelope.ok ? ` workspace ${target.workspace}: ${agents} agent(s)` : ` workspace ${target.workspace}: ${status.envelope.error?.message || "not usable"}`);
2979
2989
  if (!status.envelope.ok) process.exit(1);
2980
- } catch (e) { bail(e.code || "E_SSH", e.message); }
2990
+ } catch (e) { bail(e.code || "E_SSH", e.message, e.details); }
2981
2991
  }
2982
2992
 
2983
2993
  /** `oats <spawn|retire|status> --server <id> ...`: run the command on the
2984
2994
  * registered server's installed oats, same arguments, same envelope. The
2985
2995
  * local side only routes and keeps the route snapshot per remote instance. */
2986
2996
  async function serverRouteCmd() {
2987
- const bail = (code, msg) => (JSON_MODE ? jsonFail(code, msg) : die(msg));
2997
+ const bail = (code, msg, details, exit) => (JSON_MODE ? jsonFail(code, msg, details, exit) : die(msg, exit));
2988
2998
  const id = flag("server");
2989
2999
  if (id === true || !id) bail("E_BAD_ARGS", "--server needs a registered server id (oats server list)");
2990
- // The operations contract addresses an exact member context on the host,
2991
- // so its explicit --dir travels; every other routed command takes its
2992
- // scope from the registration.
2993
- const explicitScopeOk = ["inspect", "operation", "launch-config"].includes(cmd);
3000
+ // The operations contract, launch-config and the host's reads address an
3001
+ // exact member context on the host, so their explicit --dir travels, as
3002
+ // does a retire plan's or guarded apply's (the lifecycle contract's own
3003
+ // arguments); every other routed command takes its scope from the
3004
+ // registration.
3005
+ const explicitScopeOk = ["inspect", "operation", "launch-config", "readiness", "instance"].includes(cmd)
3006
+ || (cmd === "retire" && ["--plan", "--plan-revision", "--idempotency-key"].some((f) => args.includes(f)));
2994
3007
  if (!explicitScopeOk && flag("dir") !== undefined) bail("E_BAD_ARGS", "--dir cannot be combined with --server: the remote workspace comes from the server registration");
2995
3008
  if (cmd === "launch-config") {
2996
3009
  const action = args[1];
@@ -3014,7 +3027,7 @@ async function serverRouteCmd() {
3014
3027
  options.keepEnv = args.includes("--keep-env");
3015
3028
  }
3016
3029
  let out;
3017
- try { out = launchConfigRemote(id, options); } catch (e) { bail(e.code || "E_SSH", e.message); }
3030
+ try { out = launchConfigRemote(id, options); } catch (e) { bail(e.code || "E_SSH", e.message, e.details); }
3018
3031
  if (out.stderr?.trim()) process.stderr.write(out.stderr.endsWith("\n") ? out.stderr : out.stderr + "\n");
3019
3032
  if (JSON_MODE) { console.log(JSON.stringify(withLocalWarnings(out.envelope), null, 2)); if (!out.envelope.ok) process.exit(1); return; }
3020
3033
  if (!out.envelope.ok) die(`${id}: ${out.envelope.error?.message || "launch configuration request failed"} (${out.envelope.error?.code || "E_REMOTE"})`);
@@ -3031,7 +3044,7 @@ async function serverRouteCmd() {
3031
3044
  const inst = flag("instance");
3032
3045
  if (!inst || inst === true) bail("E_BAD_ARGS", "okf harvest --server needs --instance <name> (spawned from here)");
3033
3046
  let routed;
3034
- try { routed = routeCommand(id, "harvest", [inst]); } catch (e) { bail(e.code || "E_SSH", e.message); }
3047
+ try { routed = routeCommand(id, "harvest", [inst]); } catch (e) { bail(e.code || "E_SSH", e.message, e.details); }
3035
3048
  if (routed.stderr?.trim()) process.stderr.write(routed.stderr.endsWith("\n") ? routed.stderr : routed.stderr + "\n");
3036
3049
  if (JSON_MODE) { console.log(JSON.stringify(withLocalWarnings(routed.envelope), null, 2)); if (!routed.envelope.ok) process.exit(1); return; }
3037
3050
  if (!routed.envelope.ok) die(`${id}: ${routed.envelope.error?.message || "harvest failed"} (${routed.envelope.error?.code || "E_REMOTE"})`);
@@ -3059,7 +3072,7 @@ async function serverRouteCmd() {
3059
3072
  rest.push(a);
3060
3073
  }
3061
3074
  let out;
3062
- try { out = scheduleRemote(id, rest); } catch (e) { bail(e.code || "E_SSH", e.message); }
3075
+ try { out = scheduleRemote(id, rest); } catch (e) { bail(e.code || "E_SSH", e.message, e.details); }
3063
3076
  if (out.stderr?.trim()) process.stderr.write(out.stderr.endsWith("\n") ? out.stderr : out.stderr + "\n");
3064
3077
  if (JSON_MODE) { console.log(JSON.stringify(withLocalWarnings(out.envelope), null, 2)); if (!out.envelope.ok) process.exit(1); return; }
3065
3078
  if (!out.envelope.ok) die(`${id}: ${out.envelope.error?.message || "schedule command failed"} (${out.envelope.error?.code || "E_REMOTE"})`);
@@ -3072,7 +3085,7 @@ async function serverRouteCmd() {
3072
3085
  // Desktop preflight before a remote attach: the execution host's own
3073
3086
  // inspect, relayed as its envelope; a failure is a failure, nonzero.
3074
3087
  let out;
3075
- try { out = inspectRemote(id, addr); } catch (e) { bail(e.code || "E_SSH", e.message); }
3088
+ try { out = inspectRemote(id, addr); } catch (e) { bail(e.code || "E_SSH", e.message, e.details); }
3076
3089
  if (out.stderr?.trim()) process.stderr.write(out.stderr.endsWith("\n") ? out.stderr : out.stderr + "\n");
3077
3090
  if (JSON_MODE) { console.log(JSON.stringify(withLocalWarnings(out.envelope), null, 2)); if (!out.envelope.ok) process.exit(1); return; }
3078
3091
  if (!out.envelope.ok) die(`${id}: ${out.envelope.error?.message || "inspect failed"} (${out.envelope.error?.code || "E_REMOTE"})`);
@@ -3085,7 +3098,7 @@ async function serverRouteCmd() {
3085
3098
  const choices = { ...addr, model: value("model"), launchConfig: value("launch-config"), harness: harnessFlag(value), yolo: yoloFlag() };
3086
3099
  if (flag("stop-grace") !== undefined) bail("E_BAD_ARGS", "--stop-grace is currently supported on the execution host; omit it to use the remote restart's default wait");
3087
3100
  let out;
3088
- try { out = (args[1] === "restart" ? restartRemote : startRemote)(id, choices); } catch (e) { bail(e.code || "E_SSH", e.message); }
3101
+ try { out = (args[1] === "restart" ? restartRemote : startRemote)(id, choices); } catch (e) { bail(e.code || "E_SSH", e.message, e.details); }
3089
3102
  if (out.stderr?.trim()) process.stderr.write(out.stderr.endsWith("\n") ? out.stderr : out.stderr + "\n");
3090
3103
  if (JSON_MODE) { console.log(JSON.stringify(withLocalWarnings(out.envelope), null, 2)); if (!out.envelope.ok) process.exit(1); return; }
3091
3104
  if (!out.envelope.ok) die(`${id}: ${out.envelope.error?.message || "start failed"} (${out.envelope.error?.code || "E_REMOTE"})`);
@@ -3099,7 +3112,7 @@ async function serverRouteCmd() {
3099
3112
  const file = flag("file");
3100
3113
  if (!file || file === true) bail("E_BAD_ARGS", "session upload needs --file <local path>");
3101
3114
  let r;
3102
- try { r = uploadAttachment({ file, server: id, ...addr }); } catch (e) { bail(e.code || "E_UPLOAD_FAILED", e.message); }
3115
+ try { r = uploadAttachment({ file, server: id, ...addr }); } catch (e) { bail(e.code || "E_UPLOAD_FAILED", e.message, e.details); }
3103
3116
  if (r.stderr) process.stderr.write(r.stderr + "\n");
3104
3117
  if (JSON_MODE) { jsonOk(r); return; }
3105
3118
  console.log(`Uploaded ${r.name} (${r.bytes} bytes) to ${r.instance || r.home} on ${id}: ${r.path}`);
@@ -3108,13 +3121,22 @@ async function serverRouteCmd() {
3108
3121
  if (args[1] !== "attach") bail("E_USAGE", "--server routes `session inspect`, `session start`, `session restart`, `session upload` and `session attach`; input runs on the execution host (the wake broker calls it there)");
3109
3122
  let route;
3110
3123
  try { route = attachArgv(id, addr, { skipVersionCheck: args.includes("--print") }); }
3111
- catch (e) { bail(e.code || "E_BAD_ARGS", e.message); }
3124
+ // ssh failing before the viewer (the version probe, a name resolved through the host's roster)
3125
+ // is the failure ssh has under it: exit 255, on which a caller reconnects. Every other refusal,
3126
+ // and an ssh that never started (no link can come back), exits 1.
3127
+ catch (e) { bail(e.code || "E_BAD_ARGS", e.message, e.details, e.code === "E_SSH" && e.details?.sshStarted !== false ? 255 : 1); }
3112
3128
  if (args.includes("--print")) { console.log(route.argv.map(shellQuote).join(" ")); return; }
3113
3129
  const r = spawnSyncProc(route.argv[0], route.argv.slice(1), { stdio: "inherit" });
3130
+ // ssh exits 255 for its own failures: a link that died under the viewer
3131
+ // (keepalives unanswered, the master or the host's sshd gone), or one
3132
+ // never made. Say what is known rather than ending silently.
3133
+ if (r.status === 255) console.error(`\noats: ssh to ${route.target.sshHost} ended with an error (exit 255); if the link was lost, the instance keeps running on ${id}. Reattach with: oats session attach --server ${id} --home ${shellQuote(route.home)}`);
3114
3134
  process.exit(r.status ?? 1);
3115
3135
  }
3116
3136
  // A spawn's argv is checked here, before the server is contacted.
3117
3137
  if (cmd === "spawn") {
3138
+ const herdr = herdrSpawnFlag();
3139
+ if (herdr) { const e = herdrSettingRemoved(`${herdr} was given`); bail(e.code, e.message); }
3118
3140
  const local = args.slice(1).filter((a, i, all) => a !== "--server" && all[i - 1] !== "--server");
3119
3141
  const problem = spawnArgvProblem(local);
3120
3142
  if (problem) bail("E_BAD_ARGS", problem);
@@ -3156,9 +3178,16 @@ async function serverRouteCmd() {
3156
3178
  }
3157
3179
  let routed;
3158
3180
  try { routed = routeCommand(id, cmd, rest); }
3159
- catch (e) { bail(e.code || "E_SSH", e.message); }
3181
+ catch (e) { bail(e.code || "E_SSH", e.message, e.details); }
3160
3182
  const { envelope, stderr } = routed;
3161
3183
  if (stderr && stderr.trim()) process.stderr.write(stderr.endsWith("\n") ? stderr : stderr + "\n");
3184
+ // The host's own reads and plans: its envelope, relayed unchanged.
3185
+ if (cmd === "readiness" || cmd === "instance" || (cmd === "retire" && rest.includes("--plan"))) {
3186
+ if (JSON_MODE) { console.log(JSON.stringify(withLocalWarnings(envelope), null, 2)); if (!envelope.ok) process.exit(1); return; }
3187
+ if (!envelope.ok) die(`${id}: ${envelope.error?.message || "remote command failed"} (${envelope.error?.code || "E_REMOTE"})`);
3188
+ console.log(JSON.stringify(envelope.result, null, 2));
3189
+ return;
3190
+ }
3162
3191
  if (JSON_MODE) { console.log(JSON.stringify(withLocalWarnings(envelope), null, 2)); if (!envelope.ok || envelope.result?.rollbackIncomplete) process.exit(1); return; }
3163
3192
  if (!envelope.ok && !(cmd === "retire" && envelope.result)) die(`${id}: ${envelope.error?.message || "remote command failed"} (${envelope.error?.code || "E_REMOTE"})`);
3164
3193
  const r = envelope.result;
@@ -3188,7 +3217,7 @@ async function serverRouteCmd() {
3188
3217
  console.log(`oats status — server ${id} (ssh ${r.target.sshHost}, workspace ${r.target.workspace})\n`);
3189
3218
  for (const a of r.agents || []) {
3190
3219
  console.log(` ${a.name} [work: ${a.work || "checkout"}, repo: ${a.repo || "?"}]`);
3191
- for (const i of a.instances || []) console.log(` • ${i.instance} ${i.retirePending ? "RETIRING" : i.running ? "RUNNING" : "idle"}`);
3220
+ for (const i of a.instances || []) console.log(` • ${i.instance} ${i.retirePending ? "RETIRING" : livenessWord(i)}${i.runtimeError ? ` ${i.runtimeError}` : ""}`);
3192
3221
  }
3193
3222
  const snaps = r.snapshots || [];
3194
3223
  if (snaps.length) console.log(`\n spawned from this machine: ${snaps.map((s) => s.instance).join(", ")}`);
@@ -3337,8 +3366,8 @@ Usage:
3337
3366
  remote instance lives under ~/.oats/remote/)
3338
3367
  oats server roster [--server <id>] remote roster grouped by server and saved route
3339
3368
  [--budget <ms>] [--per-target <ms>] target: one status pull per group within a total
3340
- [--json] budget (45 s, 20 s per target); saved routes are
3341
- the authority for actions
3369
+ [--json] budget (45 s, 20 s per target); every instance the
3370
+ server reports is addressable by --home or name
3342
3371
  oats server forget <id> --instance <name> drop a saved route whose remote instance is gone
3343
3372
  (the roster shows it as missingRemotely)
3344
3373
  oats retire <instance> --home <path> retire exactly that home when two agents own an
@@ -3346,17 +3375,20 @@ Usage:
3346
3375
  oats okf harvest --server <id> run the knowledge harvest in a remote instance's
3347
3376
  --instance <name> [--json] saved home on its host
3348
3377
  oats session inspect|attach --server <id> inspect (envelope) or attach a viewer (ssh PTY) for a
3349
- --instance <name> | --home <abs> remote instance over its saved route (--print shows
3350
- attach); the server needs oats 0.22.2 or later
3378
+ --instance <name> | --home <abs> remote instance, by its saved route or the server's
3379
+ roster (--print shows attach); oats 0.22.2 or later
3351
3380
  oats session start --server <id> start a stopped remote instance in its existing home
3352
- --instance <name> | --home <abs> over its saved route; the server must advertise
3381
+ --instance <name> | --home <abs> by its saved route or the server's roster; it must advertise
3353
3382
  [--model <m>] [--json] session-start (oats 0.22.9 or later)
3354
3383
  oats inspect|operation --server <id> the same commands on a registered server over its
3355
- ... [--dir <remote member>] [--home <abs>] saved route (an explicit --dir travels as is; a --home
3384
+ ... [--dir <remote member>] [--home <abs>] instance home (an explicit --dir travels as is; a --home
3356
3385
  is its own context; else the registered workspace);
3357
3386
  the server must advertise operations (oats 0.22.16 or later)
3387
+ oats readiness|instance events|git|diff|stop the Desktop's reads and lifecycle plans, and retire --plan,
3388
+ ... --server <id> run on the instance's own machine; the host's envelope is
3389
+ relayed unchanged (the host must advertise the feature)
3358
3390
  oats session upload --server <id> copy a local file into a remote instance's private
3359
- --instance <name> | --home <abs> attachments over its saved route (bytes stream on
3391
+ --instance <name> | --home <abs> attachments, by its home or name (bytes stream on
3360
3392
  --file <path> [--json] ssh stdin; sha256 verified); the server must
3361
3393
  advertise session-upload (oats 0.22.13 or later)
3362
3394
  oats onboard [<dir>] --workspace <repo ref> realize a workspace here: writes <dir>/oats-local.yaml
@@ -3401,14 +3433,14 @@ Usage:
3401
3433
  [--model <m>] [--json] (same identity, worktree, notes and launch env; no
3402
3434
  spawn hooks); --model replaces the recorded model
3403
3435
  for this and later starts; a live harness is refused
3404
- oats spawn <agent> [--task <text> | --task-file <f>] spawn an instance (tmux/Herdr; --no-launch
3436
+ oats spawn <agent> [--task <text> | --task-file <f>] spawn an instance (tmux; --no-launch
3405
3437
  [--purpose <slug>] [--repo <r>] = scaffold only); the agent is a workspace
3406
3438
  [--parent <instance>] soul or a capability-defined agent
3407
3439
  [--relation child|sibling|parent|unrelated] --relation + --relative-to anchor the
3408
3440
  [--relative-to <instance>] new instance to an existing one; --parent X
3409
3441
  [--relative-root <agents-root>] disambiguates same-named team anchors
3410
3442
  [--work worktree|checkout|attached|workspace|directory] = sugar for --relative-to X --relation
3411
- [--work-dir <owner-work>] [--harness pi|claude|codex] [--backend tmux|herdr] [--herdr-socket <path>] [--yolo|--no-yolo] [--model <m>] [--branch <b>] child (default: unrelated, top-level)
3443
+ [--work-dir <owner-work>] [--harness pi|claude|codex] [--backend tmux] [--yolo|--no-yolo] [--model <m>] [--branch <b>] child (default: unrelated, top-level)
3412
3444
  [--no-launch] [--json] without --launch-config/--harness the launch is the
3413
3445
  soul's preference: oats-local.yaml souls.launch.<soul>,
3414
3446
  then souls.launch."*", then the soul's launch:, then pi
@@ -34,6 +34,8 @@ souls:
34
34
 
35
35
  host:
36
36
  name: ana-laptop # which workspace triggers and schedules run here
37
+ session: # terminal defaults for NEW launches on this host (0.31)
38
+ tmuxSession: oats-agents # the tmux session new tmux instances open in
37
39
  triggers:
38
40
  disabled: [platform/nightly-review]
39
41
  schedules:
@@ -64,6 +66,7 @@ refused (`E_WORKSPACE_SCHEMA`).
64
66
  | `souls.teams` | Which teams each soul joins here: `"*"` applies to every soul; a soul's own entry (its name, or `<package>/<soul>`) adds to it. Every soul is also in its default team. Written by `oats soul teams <soul>\|'*' --add … --remove …`. |
65
67
  | `souls.default` | A per-soul override of `defaultTeam`; it must be one of that soul's teams here (`E_TEAM_NOT_ELIGIBLE`). Written by `oats soul teams <soul> --default <label>`. |
66
68
  | `souls.disabled` | Souls not run on this machine; a spawn is refused with `E_SOUL_DISABLED`. A bare name disables every soul of that name; `<package>/<soul>` or `<member>/<soul>` disables one. |
69
+ | `session.tmuxSession` | The tmux session new tmux instances open their windows in (0.31). Absent: `OATS_TMUX_SESSION`, else `PI_AGENTS_TMUX_SESSION` (the pre-0.31 variable), else `oats-agents`. `session: { tmuxSession: pi-agents }` keeps the pre-0.31 layout. `oats inspect --json` reports it as `session`. |
67
70
  | `host.name` | This machine's name. A workspace trigger or schedule runs only on the host named by its `runsOn` ([schedules.md](schedules.md)). |
68
71
  | `automations.trust` | The workspace triggers and schedules (`<member>/<id>`) this host agrees to run, or `"*"` for every one the workspace places here (0.30). Absent or empty: none runs. See [Who runs workspace automations](#who-runs-workspace-automations). |
69
72
  | `triggers.disabled`, `schedules.disabled` | Workspace triggers and schedules (`<member>/<id>`) this host does not run, without a commit. Written by `oats trigger disable` / `oats schedule disable`. |
@@ -30,8 +30,9 @@ canonical (`github.com/<org>/<repo>`, or `local/<abs-path>`). Examples use
30
30
 
31
31
  ```json
32
32
  {"schemaVersion":1,"name":"@awebai/oats","version":"0.30.0","desktopApi":1,
33
- "harnesses":["pi","claude","codex"],"sessionBackends":["tmux","herdr"],"launchOptions":["yolo"],
34
- "remote":["spawn","retire","status","session","session-start","session-restart","launch-config","roster","harvest","schedule","session-upload","operations"],
33
+ "harnesses":["pi","claude","codex"],"sessionBackends":["tmux"],"launchOptions":["yolo"],
34
+ "remote":["spawn","retire","status","session","session-start","session-restart","launch-config","roster","harvest","schedule","session-upload","operations",
35
+ "readiness","instance-events","instance-git","lifecycle-plans"],
35
36
  "features":["retire-home","session-start","session-restart","launch-config","schedule","session-upload","operations","instance-git",
36
37
  "instance-git-remote","souls-declarations","lifecycle-plans","retire-retention","readiness","spawn-preview","instance-events",
37
38
  "instance-events-2","schedule-history","schedule-read-2","spawn-preview-2","spawn-idempotency","spawn-idempotency-2","spawn-apply-2",
@@ -47,10 +48,14 @@ canonical (`github.com/<org>/<repo>`, or `local/<abs-path>`). Examples use
47
48
  accepted. The real gate is the feature list; its minimum is
48
49
  `packages-no-approval`.
49
50
  - `harnesses` is what `--harness` accepts; `sessionBackends` what `--backend`
50
- accepts. A host without the `harness` feature lists `runtimes` instead.
51
+ accepts: `["tmux"]` since 0.31.0, when Herdr was removed (`--backend herdr`
52
+ is refused with `E_HERDR_REMOVED`). A host without the `harness` feature
53
+ lists `runtimes` instead.
51
54
  - `remote` is the routed surface: the commands `--server <id>` sends to a
52
55
  registered server, plus `roster`. The Desktop checks the execution host's
53
- probe before a routed mutation.
56
+ probe before a routed mutation. From 0.31: `readiness`, `instance-events`,
57
+ `instance-git` and `lifecycle-plans` name the
58
+ [routed reads and plans](#routed-reads-and-plans).
54
59
  - In text mode the command prints `@awebai/oats <version> (desktop API v1)`.
55
60
 
56
61
  ### Features
@@ -149,6 +154,20 @@ string for this: to support older kernels, use the spaced form.
149
154
  | `E_LOCAL_MISSING` | No `oats-local.yaml` in reach of `--dir` or the working directory |
150
155
  | `E_UNSUPPORTED_MODE` | A home or selector the kernel no longer runs (below) |
151
156
 
157
+ <a id="ssh-failures-e_ssh"></a>
158
+ ### ssh failures (`E_SSH`)
159
+
160
+ A routed command (`--server`, and `oats server check`) reports ssh's own
161
+ failure as `E_SSH`, message `ssh to <host> failed: …`:
162
+
163
+ - `error.details` is `{"sshStarted": false}` when ssh never started on this
164
+ machine (not installed, not executable): nothing reached the host, and
165
+ retrying cannot help.
166
+ - No `details`: ssh ran and the link failed (unreachable host, refused key,
167
+ lost connection, timeout); a retry may succeed.
168
+
169
+ (0.31.0; before it `E_SSH` never carried details.)
170
+
152
171
  Capability dispatch inside a home uses the home's module copies; from a
153
172
  deployment it resolves the module as `oats spawn --soul <x>` would and runs it
154
173
  with the soul's merged payload. `oats <namespace> --help --json` answers
@@ -1207,8 +1226,8 @@ with `--expect-decision` records the key and decision in `instance.json`.
1207
1226
 
1208
1227
  ```json
1209
1228
  {"instance":"rm-api","agent":"rm","home":"/w/agents/rm/instances/rm-api","work":"worktree","branch":"agents/rm-api","launched":true,"warnings":[],
1210
- "tmux":{"session":"pi-agents","window":"rm-api"},"repo":"/w/agents-repo","harness":"pi","model":null,"parent":null,"sibling":null,"relation":null,
1211
- "spawnOrigin":"operator","attach":"tmux attach -t pi-agents","decision":{"instance":"rm-api","revision":"c557d8ec9a272ba1c1739dc3"},"replayed":false,
1229
+ "tmux":{"session":"oats-agents","window":"rm-api"},"backend":"tmux","repo":"/w/agents-repo","harness":"pi","model":null,"parent":null,"sibling":null,"relation":null,
1230
+ "spawnOrigin":"operator","attach":"tmux attach -t oats-agents","decision":{"instance":"rm-api","revision":"c557d8ec9a272ba1c1739dc3"},"replayed":false,
1212
1231
  "wake":{"requested":false,"saved":null,"error":null},"launchConfig":null,
1213
1232
  "launch":{"version":2,"harness":"pi","launchConfig":null,"launchConfigSource":null,"executable":"/usr/local/bin/pi","executableDeclared":null,
1214
1233
  "executableResolvedFrom":"PATH","args":[],"env":{},"model":null,"hooks":{"launch":{},"env":{},"contributions":[]},"prompt":{"kind":"task-file","file":"TASK.md"}}}
@@ -1217,10 +1236,11 @@ with `--expect-decision` records the key and decision in `instance.json`.
1217
1236
  (`decision` is abridged: it is the full bound decision.)
1218
1237
 
1219
1238
  - Always present: `instance, agent, home, work, branch, launched, warnings
1220
- (array), tmux ({session, window} | null), repo, harness, model, parent,
1239
+ (array), tmux ({session, window} | null), backend ("tmux"), repo, harness,
1240
+ model, parent,
1221
1241
  sibling, relation, spawnOrigin (operator | instance), attach, launchConfig,
1222
1242
  launch` (the redacted recipe).
1223
- - When they apply: `sessionTarget` (Herdr), `yolo`, `decision` and
1243
+ - When they apply: `yolo`, `decision` and
1224
1244
  `replayed` (bound apply), `wake` (keyed apply), `wakeSchedule` and
1225
1245
  `wakeScheduleError` (a requested wake).
1226
1246
 
@@ -1297,7 +1317,7 @@ workspace-model fields (feature `instance-modules`):
1297
1317
  ```
1298
1318
 
1299
1319
  Abridged: the record also carries the launch recipe and command,
1300
- composition evidence, the capability runtime, the tmux or Herdr target,
1320
+ composition evidence, the capability runtime, the tmux target,
1301
1321
  lineage (`parentInstance`, `siblingInstance`, `relation`, `relativeTo`), and
1302
1322
  the keyed-spawn fields `decision`, `spawnIdempotencyKey`, `spawnCompleted` and
1303
1323
  `wake`; later starts add `restarts` and `restartCount`.
@@ -1354,8 +1374,13 @@ Not an envelope: `{root, agents, workspace?, problems?, warnings?}`.
1354
1374
  description, dir, instances}`.
1355
1375
  - **Instance rows**: the home's `instance.json` (launch recipe and command
1356
1376
  redacted) plus `home` and `instance` (from the directory; a disagreeing
1357
- claim is kept as `recordedHome`/`recordedInstance`), `running` (`null` when
1358
- a Herdr session is unreachable, with `runtimeState`/`runtimeError`),
1377
+ claim is kept as `recordedHome`/`recordedInstance`), `running` (read from
1378
+ the row's recorded tmux socket and session, never the caller's `$TMUX`;
1379
+ `null` with `runtimeState: "unreachable"` and the tmux error as
1380
+ `runtimeError` when that server cannot be read; `null` for a home a
1381
+ Herdr-era kernel recorded, with `runtimeState: "unsupported"` and
1382
+ `runtimeError: "E_HERDR_REMOVED: …"`, the recorded `sessionTarget` staying
1383
+ in the row),
1359
1384
  `identity` when a provider recorded one, `rollbackIncomplete` and
1360
1385
  `retirePending` when present, and the Desktop facts below.
1361
1386
  - **`modules`** becomes drift rows `{name, from, commit, current, status,
@@ -1379,6 +1404,82 @@ restart, else `createdAt` for a launched home, else `null`. `modelFrom` is
1379
1404
  `"harness-default"`, or `null` for an older home. `identityAddress` is the
1380
1405
  messaging identity's `address` (else `alias`), or `null`.
1381
1406
 
1407
+ <a id="the-remote-roster-oats-server-roster---json"></a>
1408
+ ### The remote roster (`oats server roster --json`)
1409
+
1410
+ ```text
1411
+ oats server roster [--server <id>] [--per-target <ms>] [--budget <ms>] --json
1412
+ ```
1413
+
1414
+ An envelope; `result` is `{groups, bounds}` (remote `roster`,
1415
+ [servers.md](servers.md#the-roster-and-harvest)). One group per server id and
1416
+ route target:
1417
+
1418
+ ```json
1419
+ {"id":"build:3f2a…","server":"build","label":"Build box","registrationPresent":true,
1420
+ "target":{"sshHost":"build-host","workspace":"/srv/team","oatsPath":"oats"},
1421
+ "probe":{"ok":true},"agentsRoot":"/srv/team/agents",
1422
+ "souls":[{"name":"dev","harness":"claude","work":"worktree","agentsRoot":"/srv/team/agents"}],
1423
+ "instances":[{"server":"build","instance":"dev-a","agent":"dev","home":"/srv/team/agents/dev/instances/dev-a",
1424
+ "agentsRoot":"/srv/team/agents","harness":"claude","backend":"tmux","tmux":{"session":"oats-agents","window":"dev-a"},
1425
+ "running":true,"identity":{"alias":"dev-a","address":"acme/dev-a"},"identityAddress":"acme/dev-a",
1426
+ "teams":[{"label":"default","team":"acme:team"}],"startedAt":"2026-09-29T10:00:00.000Z","createdAt":"2026-09-29T09:58:12.004Z",
1427
+ "model":"opus","runtimeState":null,"parentInstance":"lead","siblingInstance":null,"relation":"child","relativeTo":"lead",
1428
+ "spawnOrigin":"instance","retirePending":false,"rollbackIncomplete":false,
1429
+ "savedRoute":false,"addressable":true,"missingRemotely":false}],
1430
+ "retireFailures":[]}
1431
+ ```
1432
+
1433
+ - **Instance rows** relay the host's own `status --json` row: `identity`,
1434
+ `identityAddress`, `teams`, `startedAt`, `createdAt`, `model`,
1435
+ `runtimeState`, `parentInstance`, `siblingInstance`, `relation`,
1436
+ `relativeTo` and `spawnOrigin` are always present, `null` when the host
1437
+ does not supply them (a host before 0.31, a fact it never recorded, or a
1438
+ saved route the host no longer lists). Nothing is derived on this side.
1439
+ - **`addressable`** (0.31): `true` for every row the host reports. Routed
1440
+ session and lifecycle commands reach it by `--home`, or by name when the
1441
+ name is unique on the host ([addressing](servers.md#run-there); a shared
1442
+ name is `E_AMBIGUOUS` with `error.details.candidates: [{agent, home}]`). A
1443
+ saved-route row the host did not list is addressable only while the host's
1444
+ answer is unknown (`missingRemotely: false`).
1445
+ - **`savedRoute`**: the instance was spawned from this machine and has a
1446
+ saved route here. Information only; no action depends on it.
1447
+ - `running` is `null` when unknown; `backend` is `tmux` for a row with a tmux
1448
+ target, else `null`; `tmux`, `sessionTarget` (the recorded target of a home
1449
+ a Herdr-era kernel opened) and `runtimeError` are as the host reports them.
1450
+
1451
+ <a id="routed-reads-and-plans"></a>
1452
+ ### Routed reads and plans (`--server`, 0.31)
1453
+
1454
+ The Desktop's per-instance reads and the lifecycle plans run on the
1455
+ instance's own machine: the local command, with `--server <id>` added.
1456
+
1457
+ | Command | `remote` entry | The host must advertise |
1458
+ |---|---|---|
1459
+ | `oats readiness --server <id> (--home <abs> \| --soul <n>) …` | `readiness` | `readiness`, `readinessApi: 2` |
1460
+ | `oats instance events <name> --server <id> …` | `instance-events` | `instance-events-2`, `eventsApi: 2` |
1461
+ | `oats instance git\|diff <name> --server <id> …` | `instance-git` | `instance-git`, `instanceGitApi: 1` |
1462
+ | `oats instance stop <name> --server <id> (--plan \| --apply …)` | `lifecycle-plans` | `lifecycle-plans`, `lifecycleApi: 1` |
1463
+ | `oats retire <name> --server <id> --plan`, and the guarded apply (`--plan-revision`, `--idempotency-key`) | `lifecycle-plans` | `lifecycle-plans`, `lifecycleApi: 1` |
1464
+
1465
+ - The flags are the local command's. The instance is addressed like every
1466
+ routed instance command ([servers.md](servers.md#run-there)): `--home` as
1467
+ given, else the name through its saved route or the host's roster, sent
1468
+ as `--home`. `--dir` names a directory on the host and travels as is;
1469
+ without it the registered workspace is sent (not for `readiness --home`,
1470
+ whose home is its own context). A retire plan and its guarded apply take
1471
+ `--dir` like the rest; an unguarded `retire --server` refuses it.
1472
+ - An instance with a saved route is reached through it, registration or not.
1473
+ A guarded retire apply whose name the host no longer lists is sent by name,
1474
+ so a repeated key gets the host's recorded receipt (or its refusal).
1475
+ - The host's envelope is relayed unchanged, success or failure: the same
1476
+ document the local command answers, with no routing keys added. The
1477
+ guarded retire apply is the routed `retire`, whose result carries
1478
+ `server` and `target` as before.
1479
+ - A host that does not advertise the feature and API number is refused with
1480
+ `E_REMOTE_INCOMPATIBLE`, naming both and the host's version, before
1481
+ anything is sent. A name two homes share on the host is `E_AMBIGUOUS`.
1482
+
1382
1483
  <a id="instance-git-state-oats-instance-gitdiff-instancegitapi-1-oats-0247"></a>
1383
1484
  ## Git and diff
1384
1485
 
@@ -67,4 +67,6 @@ before launch. Desktop does not scaffold a home or execute a launcher itself.
67
67
  Status collection reads each instance's recorded tmux socket and session,
68
68
  with one query per socket per collection. A launcher shell with a harness
69
69
  child remains running; a fallback shell or dead pane is stopped. Errors that
70
- prevent a reliable observation remain unknown. Herdr uses its saved target.
70
+ prevent a reliable observation remain unknown. A row that records a Herdr
71
+ target (Herdr was removed in 0.31.0) is never observed: it shows the kernel's
72
+ `E_HERDR_REMOVED` text, and Open and Start are disabled.