@awebai/oats 0.30.2 → 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,11 +23,12 @@ 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,
29
30
  officialPackageCatalog, officialCatalogFile, officialCapabilityAliases, resolvedFromHome, resolvedFromPrepared, teamEnv, isWorkspaceHome, preWorkspaceHome, isCapturedHome, capturedHomeRefusal, composeInstanceAgentsMd, parseYamlNested, withConfigFile,
30
- findInstanceHome, findInstanceHomes, workspaceOf, stopInstanceSession, ensureRoot, findRoot, findAgent, findAgentAt, legacyLocalAgents, legacyCapturedHomes, listAgents, listInstances, servedIdentityLine, spawnInstanceAsync, instanceSoulDir, launchConfigsAt, launchReportFor, explicitInstanceName, retireInstance, inspectInstanceSession, inputInstanceSession, attachInstanceSession, startInstanceSession, defaultRepo, RELATIONS, validateLaunchConfig, renderLaunchRecipe, describeLaunchCommand, redactLaunchRecipe, LAUNCH_HARNESSES, planLaunch, redactLaunchCommand, restartInstanceSession,
31
+ findInstanceHome, findInstanceHomes, workspaceOf, stopInstanceSession, ensureRoot, findRoot, findAgent, findAgentAt, legacyLocalAgents, legacyCapturedHomes, listAgents, listInstances, servedIdentityLine, spawnInstanceAsync, instanceSoulDir, recordedKernelBin, launchConfigsAt, launchReportFor, explicitInstanceName, retireInstance, inspectInstanceSession, inputInstanceSession, attachInstanceSession, startInstanceSession, defaultRepo, RELATIONS, validateLaunchConfig, renderLaunchRecipe, describeLaunchCommand, redactLaunchRecipe, LAUNCH_HARNESSES, planLaunch, redactLaunchCommand, restartInstanceSession,
31
32
  } from "../lib/core.mjs";
32
33
  import {
33
34
  writeFileAtomic, LOCK_FILE, readLock, writeLock, resolvePackages, memoizedRemote,
@@ -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,9 +1790,12 @@ 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)}`);
1796
+ // The kernel the home's plain `oats` runs (its last launch's), when it is not this one.
1797
+ const launchedBy = i.home ? recordedKernelBin(i.home) : null;
1798
+ if (typeof launchedBy === "string" && (verbose || launchedBy !== CLI_BIN)) console.log(` kernel: ${launchedBy}${launchedBy !== CLI_BIN ? " (not this oats)" : ""}`);
1795
1799
  const s = ws?.soul.get(key);
1796
1800
  if (s && (verbose || s.status !== "current")) console.log(` ${soulDriftLine(s, a.name)}`);
1797
1801
  const rows = ws?.drift.get(key) || [];
@@ -1803,13 +1807,47 @@ async function status() {
1803
1807
  }
1804
1808
  }
1805
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";
1812
+ /** The flags `oats spawn` reads: those taking a value, and switches. `--provider` takes two words.
1813
+ * `--instance` is refused by a local spawn (with its replacement) but still travels to an older
1814
+ * host through `--server`, whose route reads it. */
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"]);
1816
+ const SPAWN_SWITCHES = new Set(["allow-child-spawns", "json", "no-child-spawns", "no-launch", "no-yolo", "preview", "yolo"]);
1817
+ /** Why `argv` (after `spawn`, the soul first) is not a spawn, or undefined: a positional after the
1818
+ * soul or a flag spawn does not read is never ignored. A value flag consumes its value exactly as
1819
+ * flag() reads it; a missing value is the flag's own check. */
1820
+ function spawnArgvProblem(argv) {
1821
+ const soul = argv[0];
1822
+ for (let i = 1; i < argv.length; i++) {
1823
+ const a = argv[i];
1824
+ if (a === "--provider") { i += 2; continue; }
1825
+ if (a.startsWith("--")) {
1826
+ const name = a.slice(2);
1827
+ if (SPAWN_SWITCHES.has(name)) continue;
1828
+ if (!SPAWN_VALUE_FLAGS.has(name)) return `oats spawn: unknown flag ${a}`;
1829
+ if (argv[i + 1] !== undefined && !argv[i + 1].startsWith("--")) i++;
1830
+ continue;
1831
+ }
1832
+ if (/^[A-Za-z0-9_.-]+=/.test(a)) return `oats spawn: unexpected argument ${JSON.stringify(a)} after the soul ${JSON.stringify(soul)}: a capability setting is given as --provider <capability> key=value (here: --provider <capability> ${a})`;
1833
+ return `oats spawn: unexpected argument ${JSON.stringify(a)} after the soul ${JSON.stringify(soul)}: spawn takes one soul`;
1834
+ }
1835
+ return undefined;
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
+ }
1806
1843
  async function spawnCmd() {
1807
1844
  // JSON mode: contract envelope, stable error codes, stderr-only progress.
1808
1845
  const bail = (code, msg, details) => (JSON_MODE ? jsonFail(code, msg, details) : die(msg));
1809
1846
  const note = (msg) => (JSON_MODE ? console.error(msg) : console.log(msg));
1810
1847
  const yolo = yoloFlag();
1811
- const backend = valueFlag("backend"), herdrSocket = valueFlag("herdr-socket");
1812
- 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");
1813
1851
  const requestedWork = valueFlag("work");
1814
1852
  const workDir = valueFlag("work-dir"), branch = valueFlag("branch"), repo = valueFlag("repo");
1815
1853
  const checkDirectoryOptions = (work) => {
@@ -1817,7 +1855,7 @@ async function spawnCmd() {
1817
1855
  };
1818
1856
  checkDirectoryOptions(requestedWork); // before anything is resolved or written
1819
1857
  const name = args[1];
1820
- 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]");
1821
1859
  // Retired boundary flags (maintainer transport ruling): fail LOUDLY before
1822
1860
  // ANY side effect, including root discovery.
1823
1861
  // Local souls (local-agents/) are gone with the workspace model: a soul is a member
@@ -1831,6 +1869,7 @@ async function spawnCmd() {
1831
1869
  // The slug rule is checked here too, before any side effect (soul fetch).
1832
1870
  if (nameFlag !== undefined) { try { explicitInstanceName(String(nameFlag)); } catch (e) { bail(e.code, e.message); throw e; } }
1833
1871
  if (args.includes("--ephemeral")) bail("E_BAD_ARGS", "--ephemeral was removed by the runtime-boundary ruling — declare the agent in a capability manifest (agents:) for automatic ephemeral semantics");
1872
+ { const problem = spawnArgvProblem(args.slice(1)); if (problem) bail("E_BAD_ARGS", problem); }
1834
1873
  let root;
1835
1874
  // A spawn needs a workspace deployment (lead decision c3-1): no oats-local.yaml
1836
1875
  // in reach is E_LOCAL_MISSING, before anything else is read. The agents root is
@@ -2010,7 +2049,7 @@ async function spawnCmd() {
2010
2049
  // An attached instance's repository is its work tree owner's (derived by the kernel).
2011
2050
  repo: preparedRepo !== undefined ? preparedRepo : ["directory", "attached"].includes(requestedWork || agent.work)
2012
2051
  ? repo : repo || defaultRepo(workspaceOf(root)) || defaultRepo(process.cwd()),
2013
- work: requestedWork, workDir, harness: harnessFlag(), backend, herdrSocket, yolo, model: flag("model"), branch,
2052
+ work: requestedWork, workDir, harness: harnessFlag(), backend, yolo, model: flag("model"), branch,
2014
2053
  launchConfig: valueFlag("launch-config"),
2015
2054
  launch: !args.includes("--no-launch"),
2016
2055
  ...(triggerEvent ? { triggerEvent } : {}),
@@ -2077,11 +2116,10 @@ async function spawnCmd() {
2077
2116
  instance: r.instance, agent: r.agent, home: r.home, work: r.work,
2078
2117
  branch: r.branch || null, launched: r.launched, warnings: r.warnings || [],
2079
2118
  ...(wakeSchedule ? { wakeSchedule } : {}), ...(wakeScheduleError ? { wakeScheduleError } : {}),
2080
- 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,
2081
2120
  model: r.model || null, parent: r.parentInstance || null,
2082
2121
  sibling: r.siblingInstance || null, relation: r.relation || null,
2083
2122
  spawnOrigin: r.spawnOrigin, attach: r.attach,
2084
- ...(r.sessionTarget ? { sessionTarget: r.sessionTarget } : {}),
2085
2123
  ...(r.yolo !== undefined ? { yolo: r.yolo } : {}),
2086
2124
  // K6b/K6c: what bound this spawn, and whether this receipt is a replay of an earlier one.
2087
2125
  ...(r.decision ? { decision: r.decision } : {}), ...(r.replayed !== undefined ? { replayed: r.replayed } : {}),
@@ -2090,7 +2128,7 @@ async function spawnCmd() {
2090
2128
  });
2091
2129
  return;
2092
2130
  }
2093
- 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"}`);
2094
2132
  console.log(` home: ${shortPath(r.home)}`);
2095
2133
  if (wakeSchedule) console.log(` wake: schedule ${wakeSchedule.id} (${wakeSchedule.cron} ${wakeSchedule.tz}), next ${wakeSchedule.nextRun || "disabled"}`);
2096
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)`);
@@ -2823,7 +2861,7 @@ function versionCmd() {
2823
2861
  // Phase B: `instance-modules` and `spawn-provider-payload` are advertised only once spawn
2824
2862
  // runs on resolve/materialize (contract §6); a feature the binary does not implement is
2825
2863
  // never listed.
2826
- 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 }));
2827
2865
  return;
2828
2866
  }
2829
2867
  console.log(`@awebai/oats ${OATS_VERSION} (desktop API v1)`);
@@ -2867,9 +2905,9 @@ async function experimentalCmd() {
2867
2905
  * an OpenSSH host alias, the remote workspace, the remote oats path. Keys
2868
2906
  * and passwords never enter it; ssh owns those. */
2869
2907
  function serverCmd() {
2870
- 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));
2871
2909
  const sub = args[1];
2872
- 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]";
2873
2911
  if (!["add", "list", "remove", "check", "roster", "forget"].includes(sub)) bail("E_USAGE", usage);
2874
2912
  if (sub === "forget") {
2875
2913
  // A saved route whose remote instance is gone can be dropped only by
@@ -2910,15 +2948,16 @@ function serverCmd() {
2910
2948
  const rows = Object.entries(servers).map(([id, s]) => ({ id, ...s, target: targetOf({ id, ...s }), snapshots: listSnapshots(id).length }));
2911
2949
  if (JSON_MODE) { jsonOk({ file: SERVERS_FILE(), servers: rows }); return; }
2912
2950
  if (!rows.length) { console.log(`no servers registered (${shortPath(SERVERS_FILE())}) — add one with \`oats server add <id> --ssh <alias> --workspace </path>\``); return; }
2913
- 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)` : ""}`);
2914
2952
  return;
2915
2953
  }
2916
2954
  const id = args[2];
2917
2955
  if (!id || id.startsWith("--")) bail("E_USAGE", usage);
2918
2956
  if (sub === "add") {
2919
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); }
2920
2959
  const entry = { sshHost: val("ssh"), workspace: val("workspace") };
2921
- 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; }
2922
2961
  if (!entry.sshHost || !entry.workspace) bail("E_USAGE", usage);
2923
2962
  try { validateServer(id, entry); } catch (e) { bail(e.code, e.message); }
2924
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)`);
@@ -2941,27 +2980,30 @@ function serverCmd() {
2941
2980
  let server; try { server = getServer(id); } catch (e) { bail(e.code, e.message); }
2942
2981
  const target = targetOf(server);
2943
2982
  try {
2944
- const remote = checkRemote(target);
2983
+ const remote = checkRemote(target, { serverId: id });
2945
2984
  const status = routeCommand(id, "status", [], { server });
2946
2985
  const agents = status.envelope.ok ? (status.envelope.result.agents || []).length : undefined;
2947
2986
  if (JSON_MODE) { jsonOk({ id, target, remote, workspaceReachable: !!status.envelope.ok, agents, error: status.envelope.ok ? undefined : status.envelope.error }); return; }
2948
2987
  console.log(`${id}: ssh ${target.sshHost} ok, remote oats ${remote.version} (envelope v${remote.schemaVersion})`);
2949
2988
  console.log(status.envelope.ok ? ` workspace ${target.workspace}: ${agents} agent(s)` : ` workspace ${target.workspace}: ${status.envelope.error?.message || "not usable"}`);
2950
2989
  if (!status.envelope.ok) process.exit(1);
2951
- } catch (e) { bail(e.code || "E_SSH", e.message); }
2990
+ } catch (e) { bail(e.code || "E_SSH", e.message, e.details); }
2952
2991
  }
2953
2992
 
2954
2993
  /** `oats <spawn|retire|status> --server <id> ...`: run the command on the
2955
2994
  * registered server's installed oats, same arguments, same envelope. The
2956
2995
  * local side only routes and keeps the route snapshot per remote instance. */
2957
2996
  async function serverRouteCmd() {
2958
- 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));
2959
2998
  const id = flag("server");
2960
2999
  if (id === true || !id) bail("E_BAD_ARGS", "--server needs a registered server id (oats server list)");
2961
- // The operations contract addresses an exact member context on the host,
2962
- // so its explicit --dir travels; every other routed command takes its
2963
- // scope from the registration.
2964
- 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)));
2965
3007
  if (!explicitScopeOk && flag("dir") !== undefined) bail("E_BAD_ARGS", "--dir cannot be combined with --server: the remote workspace comes from the server registration");
2966
3008
  if (cmd === "launch-config") {
2967
3009
  const action = args[1];
@@ -2985,7 +3027,7 @@ async function serverRouteCmd() {
2985
3027
  options.keepEnv = args.includes("--keep-env");
2986
3028
  }
2987
3029
  let out;
2988
- 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); }
2989
3031
  if (out.stderr?.trim()) process.stderr.write(out.stderr.endsWith("\n") ? out.stderr : out.stderr + "\n");
2990
3032
  if (JSON_MODE) { console.log(JSON.stringify(withLocalWarnings(out.envelope), null, 2)); if (!out.envelope.ok) process.exit(1); return; }
2991
3033
  if (!out.envelope.ok) die(`${id}: ${out.envelope.error?.message || "launch configuration request failed"} (${out.envelope.error?.code || "E_REMOTE"})`);
@@ -3002,7 +3044,7 @@ async function serverRouteCmd() {
3002
3044
  const inst = flag("instance");
3003
3045
  if (!inst || inst === true) bail("E_BAD_ARGS", "okf harvest --server needs --instance <name> (spawned from here)");
3004
3046
  let routed;
3005
- 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); }
3006
3048
  if (routed.stderr?.trim()) process.stderr.write(routed.stderr.endsWith("\n") ? routed.stderr : routed.stderr + "\n");
3007
3049
  if (JSON_MODE) { console.log(JSON.stringify(withLocalWarnings(routed.envelope), null, 2)); if (!routed.envelope.ok) process.exit(1); return; }
3008
3050
  if (!routed.envelope.ok) die(`${id}: ${routed.envelope.error?.message || "harvest failed"} (${routed.envelope.error?.code || "E_REMOTE"})`);
@@ -3030,7 +3072,7 @@ async function serverRouteCmd() {
3030
3072
  rest.push(a);
3031
3073
  }
3032
3074
  let out;
3033
- 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); }
3034
3076
  if (out.stderr?.trim()) process.stderr.write(out.stderr.endsWith("\n") ? out.stderr : out.stderr + "\n");
3035
3077
  if (JSON_MODE) { console.log(JSON.stringify(withLocalWarnings(out.envelope), null, 2)); if (!out.envelope.ok) process.exit(1); return; }
3036
3078
  if (!out.envelope.ok) die(`${id}: ${out.envelope.error?.message || "schedule command failed"} (${out.envelope.error?.code || "E_REMOTE"})`);
@@ -3043,7 +3085,7 @@ async function serverRouteCmd() {
3043
3085
  // Desktop preflight before a remote attach: the execution host's own
3044
3086
  // inspect, relayed as its envelope; a failure is a failure, nonzero.
3045
3087
  let out;
3046
- 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); }
3047
3089
  if (out.stderr?.trim()) process.stderr.write(out.stderr.endsWith("\n") ? out.stderr : out.stderr + "\n");
3048
3090
  if (JSON_MODE) { console.log(JSON.stringify(withLocalWarnings(out.envelope), null, 2)); if (!out.envelope.ok) process.exit(1); return; }
3049
3091
  if (!out.envelope.ok) die(`${id}: ${out.envelope.error?.message || "inspect failed"} (${out.envelope.error?.code || "E_REMOTE"})`);
@@ -3056,7 +3098,7 @@ async function serverRouteCmd() {
3056
3098
  const choices = { ...addr, model: value("model"), launchConfig: value("launch-config"), harness: harnessFlag(value), yolo: yoloFlag() };
3057
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");
3058
3100
  let out;
3059
- 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); }
3060
3102
  if (out.stderr?.trim()) process.stderr.write(out.stderr.endsWith("\n") ? out.stderr : out.stderr + "\n");
3061
3103
  if (JSON_MODE) { console.log(JSON.stringify(withLocalWarnings(out.envelope), null, 2)); if (!out.envelope.ok) process.exit(1); return; }
3062
3104
  if (!out.envelope.ok) die(`${id}: ${out.envelope.error?.message || "start failed"} (${out.envelope.error?.code || "E_REMOTE"})`);
@@ -3070,7 +3112,7 @@ async function serverRouteCmd() {
3070
3112
  const file = flag("file");
3071
3113
  if (!file || file === true) bail("E_BAD_ARGS", "session upload needs --file <local path>");
3072
3114
  let r;
3073
- 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); }
3074
3116
  if (r.stderr) process.stderr.write(r.stderr + "\n");
3075
3117
  if (JSON_MODE) { jsonOk(r); return; }
3076
3118
  console.log(`Uploaded ${r.name} (${r.bytes} bytes) to ${r.instance || r.home} on ${id}: ${r.path}`);
@@ -3079,11 +3121,26 @@ async function serverRouteCmd() {
3079
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)");
3080
3122
  let route;
3081
3123
  try { route = attachArgv(id, addr, { skipVersionCheck: args.includes("--print") }); }
3082
- 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); }
3083
3128
  if (args.includes("--print")) { console.log(route.argv.map(shellQuote).join(" ")); return; }
3084
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)}`);
3085
3134
  process.exit(r.status ?? 1);
3086
3135
  }
3136
+ // A spawn's argv is checked here, before the server is contacted.
3137
+ if (cmd === "spawn") {
3138
+ const herdr = herdrSpawnFlag();
3139
+ if (herdr) { const e = herdrSettingRemoved(`${herdr} was given`); bail(e.code, e.message); }
3140
+ const local = args.slice(1).filter((a, i, all) => a !== "--server" && all[i - 1] !== "--server");
3141
+ const problem = spawnArgvProblem(local);
3142
+ if (problem) bail("E_BAD_ARGS", problem);
3143
+ }
3087
3144
  // Everything after the command word travels, minus the routing flags; a
3088
3145
  // local --task-file is read here and travels as --task text, since the
3089
3146
  // remote cannot read this machine's files.
@@ -3121,9 +3178,16 @@ async function serverRouteCmd() {
3121
3178
  }
3122
3179
  let routed;
3123
3180
  try { routed = routeCommand(id, cmd, rest); }
3124
- catch (e) { bail(e.code || "E_SSH", e.message); }
3181
+ catch (e) { bail(e.code || "E_SSH", e.message, e.details); }
3125
3182
  const { envelope, stderr } = routed;
3126
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
+ }
3127
3191
  if (JSON_MODE) { console.log(JSON.stringify(withLocalWarnings(envelope), null, 2)); if (!envelope.ok || envelope.result?.rollbackIncomplete) process.exit(1); return; }
3128
3192
  if (!envelope.ok && !(cmd === "retire" && envelope.result)) die(`${id}: ${envelope.error?.message || "remote command failed"} (${envelope.error?.code || "E_REMOTE"})`);
3129
3193
  const r = envelope.result;
@@ -3153,7 +3217,7 @@ async function serverRouteCmd() {
3153
3217
  console.log(`oats status — server ${id} (ssh ${r.target.sshHost}, workspace ${r.target.workspace})\n`);
3154
3218
  for (const a of r.agents || []) {
3155
3219
  console.log(` ${a.name} [work: ${a.work || "checkout"}, repo: ${a.repo || "?"}]`);
3156
- 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}` : ""}`);
3157
3221
  }
3158
3222
  const snaps = r.snapshots || [];
3159
3223
  if (snaps.length) console.log(`\n spawned from this machine: ${snaps.map((s) => s.instance).join(", ")}`);
@@ -3302,8 +3366,8 @@ Usage:
3302
3366
  remote instance lives under ~/.oats/remote/)
3303
3367
  oats server roster [--server <id>] remote roster grouped by server and saved route
3304
3368
  [--budget <ms>] [--per-target <ms>] target: one status pull per group within a total
3305
- [--json] budget (45 s, 20 s per target); saved routes are
3306
- 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
3307
3371
  oats server forget <id> --instance <name> drop a saved route whose remote instance is gone
3308
3372
  (the roster shows it as missingRemotely)
3309
3373
  oats retire <instance> --home <path> retire exactly that home when two agents own an
@@ -3311,17 +3375,20 @@ Usage:
3311
3375
  oats okf harvest --server <id> run the knowledge harvest in a remote instance's
3312
3376
  --instance <name> [--json] saved home on its host
3313
3377
  oats session inspect|attach --server <id> inspect (envelope) or attach a viewer (ssh PTY) for a
3314
- --instance <name> | --home <abs> remote instance over its saved route (--print shows
3315
- 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
3316
3380
  oats session start --server <id> start a stopped remote instance in its existing home
3317
- --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
3318
3382
  [--model <m>] [--json] session-start (oats 0.22.9 or later)
3319
3383
  oats inspect|operation --server <id> the same commands on a registered server over its
3320
- ... [--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
3321
3385
  is its own context; else the registered workspace);
3322
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)
3323
3390
  oats session upload --server <id> copy a local file into a remote instance's private
3324
- --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
3325
3392
  --file <path> [--json] ssh stdin; sha256 verified); the server must
3326
3393
  advertise session-upload (oats 0.22.13 or later)
3327
3394
  oats onboard [<dir>] --workspace <repo ref> realize a workspace here: writes <dir>/oats-local.yaml
@@ -3366,14 +3433,14 @@ Usage:
3366
3433
  [--model <m>] [--json] (same identity, worktree, notes and launch env; no
3367
3434
  spawn hooks); --model replaces the recorded model
3368
3435
  for this and later starts; a live harness is refused
3369
- 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
3370
3437
  [--purpose <slug>] [--repo <r>] = scaffold only); the agent is a workspace
3371
3438
  [--parent <instance>] soul or a capability-defined agent
3372
3439
  [--relation child|sibling|parent|unrelated] --relation + --relative-to anchor the
3373
3440
  [--relative-to <instance>] new instance to an existing one; --parent X
3374
3441
  [--relative-root <agents-root>] disambiguates same-named team anchors
3375
3442
  [--work worktree|checkout|attached|workspace|directory] = sugar for --relative-to X --relation
3376
- [--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)
3377
3444
  [--no-launch] [--json] without --launch-config/--harness the launch is the
3378
3445
  soul's preference: oats-local.yaml souls.launch.<soul>,
3379
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`. |
@@ -151,10 +154,27 @@ source variable must be set on the host (`E_LAUNCH_ENV_MISSING`, before
151
154
  anything is created or stopped), and only the harness's pane receives it.
152
155
  `list` and `preview` redact every environment value, literals included.
153
156
 
157
+ **The instance's `oats`.** Every launch (`oats spawn`, `session start`,
158
+ `session restart`, locally or through `--server`) writes `<home>/.oats/bin/oats`,
159
+ a link to the launching kernel's `bin/oats.mjs`, and runs the harness with
160
+ `<home>/.oats/bin` first on `PATH` and the rest of `PATH` unchanged. Plain
161
+ `oats` inside an instance is therefore the kernel that launched it, even on a
162
+ machine whose `PATH` finds another kernel first. A launch configuration's own
163
+ `PATH` (literal or `fromEnv`) comes after it. A restart by a different kernel
164
+ re-points the link to that kernel; `spawn --no-launch` writes it too. The
165
+ recipe in `instance.json` records the target as `launch.kernelBin` (no JSON
166
+ answer carries it); `oats status` prints it
167
+ (`kernel:`) under `--verbose`, or when it is not the `oats` running the status.
168
+ A launch that cannot write the link fails with `E_LAUNCH_SHIM` naming the path
169
+ and the cause: a spawn is rolled back, a start starts nothing. The recorded
170
+ `command` does not carry the `PATH`; the kernel adds it when it runs the
171
+ command. Hooks still receive `OATS_CLI_BIN`, unchanged.
172
+
154
173
  **The launch recipe.** A spawn records what a start is made of in
155
174
  `instance.json` under `launch`: the harness, the configuration and where it
156
175
  came from, the executable, args, env, model, yolo, and each capability's
157
- launch contribution with its settings and trust. One renderer turns it into
176
+ launch contribution with its settings and trust, and the kernel that launched
177
+ it (`kernelBin`, re-written by every start). One renderer turns it into
158
178
  the `command`. Configuration `args` go after the harness's own options and
159
179
  before capability arguments; every argument is single-quoted.
160
180