@awebai/oats 0.30.3 → 0.32.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
@@ -19,24 +19,26 @@
19
19
  */
20
20
  import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readSync, realpathSync, rmdirSync, rmSync, writeFileSync } from "node:fs";
21
21
  import { execFileSync, spawnSync } from "node:child_process";
22
- import { homedir, tmpdir } from "node:os";
22
+ import { constants as osConstants, 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, recordedKernelBin, 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, validateLaunchConfigDefaults, renderLaunchRecipe, describeLaunchCommand, redactLaunchRecipe, LAUNCH_HARNESSES, planLaunch, redactLaunchCommand, restartInstanceSession,
31
32
  } from "../lib/core.mjs";
32
33
  import {
33
- writeFileAtomic, LOCK_FILE, readLock, writeLock, resolvePackages, memoizedRemote,
34
+ writeFileAtomic, LOCK_FILE, readLock, readLockIfPresent, writeLock, resolvePackages, memoizedRemote,
34
35
  classifyPackageValue, parsePackageRequest } from "../lib/packages.mjs";
35
- import { loadLocal, validateWorkspace, validateLocal, discoverPackageSouls, workspaceWarnings } from "../lib/workspace.mjs";
36
+ import { loadLocal, validateWorkspace, validateLocal, discoverPackageSouls, workspaceWarnings, memberRowByKey } from "../lib/workspace.mjs";
36
37
  import { recordedTeams, reportRows, soulKeyOf, soulTeams, teamModel } from "../lib/teams.mjs";
37
38
  import { launchLayers } from "../lib/launch-preference.mjs";
38
39
  import { parseConfigData } from "../lib/config-data.mjs";
39
40
  import * as remoteModule from "../lib/remote.mjs";
41
+ import { activateLocalInputs, localRevision } from "../lib/local-inputs.mjs";
40
42
  import YAML from "yaml";
41
43
  import { attachArgv, checkRemote, forgetSnapshot, getServer, inspectRemote, startRemote, restartRemote, launchConfigRemote, scheduleRemote, listSnapshots, readServers, rosterGroups, routeCommand, targetOf, validateServer, writeServers, SERVERS_FILE } from "../lib/servers.mjs";
42
44
  import { spawnSync as spawnSyncProc } from "node:child_process";
@@ -78,7 +80,7 @@ const KERNEL_COMMANDS = new Set(["automations", "trigger", "capture", "capabilit
78
80
  /** Commands whose argv another parser reads (packages/record and packages/experimental parse process.argv). */
79
81
  const OWN_ARGV_COMMANDS = new Set(["capture", "recall", "setup", "experimental"]);
80
82
  /** Commands `--server <id>` runs on a registered server. */
81
- const ROUTED_COMMANDS = new Set(["spawn", "retire", "status", "session", "okf", "schedule", "inspect", "operation", "launch-config"]);
83
+ const ROUTED_COMMANDS = new Set(["spawn", "retire", "status", "session", "okf", "schedule", "inspect", "operation", "launch-config", "readiness", "instance"]);
82
84
  const flag = (name) => {
83
85
  const i = args.indexOf(`--${name}`);
84
86
  return i >= 0 ? (args[i + 1] && !args[i + 1].startsWith("--") ? args[i + 1] : true) : undefined;
@@ -92,7 +94,7 @@ function valueFlag(name) {
92
94
  if (value === true) cmdFail("E_BAD_ARGS", `--${name} needs a value`);
93
95
  return value;
94
96
  }
95
- const die = (msg) => { console.error(`oats: ${msg}`); process.exit(1); };
97
+ const die = (msg, exit = 1) => { console.error(`oats: ${msg}`); process.exit(exit); };
96
98
  /** A command's harness: --harness, or --runtime, its pre-0.27 name (the released okf worker and
97
99
  * a 0.26-era Desktop pass it) — read either, with the deprecation warning. Both, disagreeing,
98
100
  * are refused. `get` reads one flag (the command's own reader where it has one). */
@@ -139,7 +141,7 @@ const withLocalWarnings = (envelope) => {
139
141
  const sources = [...new Set([...(Array.isArray(same.sources) ? same.sources : []), ...mine.sources])];
140
142
  return { ...envelope, warnings: theirs.map((w) => (w === same ? { ...mine, sources, message: mine.message.replace(/\(.*\)/, `(${sources.join("; ")})`) } : w)) };
141
143
  };
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); };
144
+ 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
145
  const jsonOk = (result) => { console.log(JSON.stringify({ schemaVersion: 1, ok: true, result, ...envelopeWarnings() })); };
144
146
  // Text mode (or a JSON answer printed before the read): the warning goes to stderr, never stdout.
145
147
  process.on("exit", () => { const w = runtimeNameWarning(); if (w && !warningDelivered) process.stderr.write(`oats: warning: ${w.message}\n`); });
@@ -258,7 +260,7 @@ const INSPECT_TEXT_CAP = 256 * 1024;
258
260
  /** The agents root a home belongs to, from its path alone:
259
261
  * <root>/<agent>/instances/<instance>. */
260
262
  function agentsRootOfHome(home) { return dirname(dirname(dirname(home))); }
261
- const SOUL_FIELDS = ["harness", "model", "yolo", "backend", "description", "launch-config"];
263
+ const SOUL_FIELDS = ["harness", "model", "yolo", "description", "launch-config"];
262
264
  const realOrResolved = (p) => { try { return realpathSync(p); } catch { return resolve(p); } };
263
265
  /** Every soul of a scope: the persistent souls of every agents root in
264
266
  * scope, plus packaged souls (read-only). One enumeration for inspect and
@@ -348,7 +350,7 @@ function soulEntry(soul, root, { capability } = {}) {
348
350
  declarationProblems: declared.problems,
349
351
  name: soul.name, kind: packaged ? "capability" : (soul.kind || "persistent"), capability: capability || null,
350
352
  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,
353
+ 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
354
  agentsRoot: root, dir: packaged ? soulDir : dir, soulFile: join(soulDir, "soul.yaml"), instructionsFile: join(soulDir, "AGENTS.md"),
353
355
  editable: packaged
354
356
  ? { fields: [], instructions: false, reason: `packaged soul from capability ${capability}: edit the package and update it; scoped bindings still apply through oats use` }
@@ -366,7 +368,7 @@ async function inspectCmd() {
366
368
  if (t) {
367
369
  if (t.resolutionError) return bail(t.resolutionError.code, t.resolutionError.message, t.resolutionError.details ?? undefined);
368
370
  const doc = inspectDocument(t, { kernel: OATS_VERSION });
369
- if (JSON_MODE) { jsonOk(doc); return; }
371
+ if (JSON_MODE) { jsonOk(withObservation(doc)); return; }
370
372
  printWorkspaceInspect(doc); return;
371
373
  }
372
374
  }
@@ -535,6 +537,7 @@ async function workspaceOperation(t, { bail, address, layer, opName }) {
535
537
  const env = { ...lp.env(mod.name, settings), OATS_OPERATION: address, OATS_CONTEXT: t.deployment, OATS_ROOT: t.agentsRoot, PI_AGENTS_ROOT: t.agentsRoot };
536
538
  if (op.context === "home") Object.assign(env, { OATS_INSTANCE: t.meta.instance, OATS_INSTANCE_HOME: t.home, OATS_HOME: t.home, PI_AGENT_INSTANCE: t.meta.instance, PI_AGENT_HOME: t.home });
537
539
  else for (const k of ["OATS_INSTANCE", "OATS_INSTANCE_HOME", "OATS_HOME", "PI_AGENT_INSTANCE", "PI_AGENT_HOME"]) delete env[k];
540
+ await readSession?.closeBatches(); // no idle `git cat-file --batch` child held for the provider's whole run
538
541
  const r = spawnSync("node", [abs, ...rest, ...argFlags, "--json"], { cwd, env, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 16 * 1024 * 1024, timeout: OPERATION_TIMEOUT_MS, killSignal: "SIGTERM" });
539
542
  finishOperation({ r, bail, address, provider, op, argFlags, cwd, home: t.home, meta: t.meta, api: INSPECT_OPERATIONS_API });
540
543
  }
@@ -685,6 +688,7 @@ function serializeLaunchConfigs(map) {
685
688
  }
686
689
  if (e.model !== undefined) lines.push(` model: ${yamlQuoted(e.model)}`);
687
690
  if (e.yolo !== undefined) lines.push(` yolo: ${e.yolo}`);
691
+ if (e.default === true) lines.push(" default: true");
688
692
  }
689
693
  return lines.join("\n") + "\n";
690
694
  }
@@ -699,6 +703,7 @@ function normalizeLaunchConfig(e) {
699
703
  ...(e.env && Object.keys(e.env).length ? { env: Object.fromEntries(Object.keys(e.env).sort().map((n) => [n, typeof e.env[n] === "string" ? e.env[n] : { fromEnv: e.env[n].fromEnv }])) } : {}),
700
704
  ...(e.model !== undefined ? { model: e.model } : {}),
701
705
  ...(e.yolo !== undefined ? { yolo: e.yolo } : {}),
706
+ ...(e.default === true ? { default: true } : {}),
702
707
  };
703
708
  }
704
709
  function readLaunchConfigsModel(local) {
@@ -715,7 +720,7 @@ function readLaunchConfigsModel(local) {
715
720
  * `set --keep-env`. */
716
721
  function publicLaunchConfig(e, extra = {}) {
717
722
  const env = Object.fromEntries(Object.keys(e.env || {}).sort().map((n) => [n, typeof e.env[n] === "string" ? { redacted: true } : { fromEnv: e.env[n].fromEnv }]));
718
- return { harness: e.harness, executable: e.executable ?? null, args: [...(e.args || [])], env, model: e.model ?? null, yolo: e.yolo ?? null, ...extra };
723
+ return { harness: e.harness, executable: e.executable ?? null, args: [...(e.args || [])], env, model: e.model ?? null, yolo: e.yolo ?? null, default: e.default === true, ...extra };
719
724
  }
720
725
  /** The scope a launch-config command reads: --dir (or cwd), a running
721
726
  * home's recorded context (--home), or a soul's own member context
@@ -765,7 +770,7 @@ function launchPreview(bail) {
765
770
  // (E_LAUNCH_LEGACY: re-spawn it from the deployment).
766
771
  let d;
767
772
  try { d = describeLaunchCommand(meta.command); } catch (e) { bail(e.code || "E_LAUNCH_COMMAND_UNSUPPORTED", e.message); }
768
- jsonOk({ context, selected, selection: { source: "frozen-command", launchConfig: null, harness: null, model: null, yolo: null }, harness: meta.harness, model: meta.model || null, modelSource: meta.model ? "recorded" : "native default", yolo: meta.yolo ?? null, launchConfig: null, launchConfigSource: null, executable: { path: d.executable, declared: null, resolvedFrom: "recorded" }, argv: d.argv, environment: d.environment, command: redactLaunchCommand(meta.command), prompt: { kind: "task-file", file: "TASK.md" }, hooks: null, preflight: [{ check: "recipe", ok: true, detail: "frozen command; a selection is refused (E_LAUNCH_LEGACY): re-spawn it" }], ok: true });
773
+ jsonOk({ context, selected, selection: { source: "frozen-command", launchConfig: null, harness: null, model: null, yolo: null }, harness: meta.harness, model: meta.model || null, modelSource: meta.model ? "recorded" : "native default", yolo: meta.yolo ?? null, launchConfig: null, launchConfigSource: null, launchConfigDefault: false, executable: { path: d.executable, declared: null, resolvedFrom: "recorded" }, argv: d.argv, environment: d.environment, command: redactLaunchCommand(meta.command), prompt: { kind: "task-file", file: "TASK.md" }, hooks: null, preflight: [{ check: "recipe", ok: true, detail: "frozen command; a selection is refused (E_LAUNCH_LEGACY): re-spawn it" }], ok: true });
769
774
  return;
770
775
  }
771
776
  const agentsRoot = agentsRootOfHome(home);
@@ -787,7 +792,7 @@ function launchPreview(bail) {
787
792
  const command = renderLaunchRecipe(recipe, { home, instance, redact: true });
788
793
  const d = describeLaunchCommand(command);
789
794
  const environment = d.environment.map((e) => e.reference && recipe.env[e.name]?.fromEnv ? { name: e.name, fromEnv: recipe.env[e.name].fromEnv } : e);
790
- jsonOk({ context, selected, selection: { source: plan.selectionSource, launchConfig: recipe.launchConfig, harness: sel.harness ?? null, model: sel.model ?? null, yolo: sel.yolo ?? null }, harness: plan.harness, model: recipe.model, modelSource: plan.modelSource, yolo: recipe.yolo ?? null, launchConfig: recipe.launchConfig, launchConfigSource: recipe.launchConfigSource, executable: { path: plan.executable.path, declared: plan.executable.declared ?? null, resolvedFrom: plan.executable.resolvedFrom }, argv: d.argv, environment, command, prompt: recipe.prompt, hooks: redactLaunchRecipe(recipe).hooks, preflight: plan.preflight, ok: plan.ok });
795
+ jsonOk({ context, selected, selection: { source: plan.selectionSource, launchConfig: recipe.launchConfig, harness: sel.harness ?? null, model: sel.model ?? null, yolo: sel.yolo ?? null }, harness: plan.harness, model: recipe.model, modelSource: plan.modelSource, yolo: recipe.yolo ?? null, launchConfig: recipe.launchConfig, launchConfigSource: recipe.launchConfigSource, launchConfigDefault: recipe.launchConfigDefault === true, executable: { path: plan.executable.path, declared: plan.executable.declared ?? null, resolvedFrom: plan.executable.resolvedFrom }, argv: d.argv, environment, command, prompt: recipe.prompt, hooks: redactLaunchRecipe(recipe).hooks, preflight: plan.preflight, ok: plan.ok });
791
796
  }
792
797
  async function launchConfigCmd() {
793
798
  const bail = (code, msg, details) => (JSON_MODE ? jsonFail(code, msg, details) : die(msg));
@@ -816,7 +821,7 @@ async function launchConfigCmd() {
816
821
  if (!configurations.length) { console.log(`No launch configurations are declared${file ? ` in ${shortPath(file)}` : ` (no oats-local.yaml in reach of ${dir})`}`); return; }
817
822
  for (const c of configurations) {
818
823
  const env = Object.entries(c.env).map(([n, v]) => v.fromEnv ? `${n}=$${v.fromEnv}` : `${n}=<redacted>`).join(" ");
819
- console.log(`${c.name}: ${c.harness}${c.executable ? ` ${c.executable}` : ""}${c.args.length ? ` ${c.args.map((a) => JSON.stringify(a)).join(" ")}` : ""}${env ? ` [${env}]` : ""}${c.model ? ` model ${c.model}` : ""}${c.yolo !== null ? ` yolo ${c.yolo}` : ""}`);
824
+ console.log(`${c.name}: ${c.harness}${c.default ? ` (this machine's ${c.harness} default)` : ""}${c.executable ? ` ${c.executable}` : ""}${c.args.length ? ` ${c.args.map((a) => JSON.stringify(a)).join(" ")}` : ""}${env ? ` [${env}]` : ""}${c.model ? ` model ${c.model}` : ""}${c.yolo !== null ? ` yolo ${c.yolo}` : ""}`);
820
825
  }
821
826
  return;
822
827
  }
@@ -859,6 +864,8 @@ async function launchConfigCmd() {
859
864
  try { validateLaunchConfig(name, entry, `--file ${f}`); } catch (e) { bail(e.code || "E_LAUNCH_CONFIG_INVALID", e.message); }
860
865
  if (!Object.hasOwn(entry, "harness")) noteRuntimeName(`runtime in the --file definition (written as harness)`);
861
866
  model[name] = normalizeLaunchConfig(entry);
867
+ // One default per harness, over the file as it would be written (no automatic move).
868
+ try { validateLaunchConfigDefaults(model, shortPath(file)); } catch (e) { bail(e.code || "E_LAUNCH_CONFIG_INVALID", e.message, e.details); }
862
869
  }
863
870
  let next;
864
871
  try { next = replaceLaunchConfigsBlock(text, serializeLaunchConfigs(model)); } catch (e) { bail(e.code || "E_CONFIG_BROKEN", `${e.message}; nothing was written`); }
@@ -1012,11 +1019,60 @@ async function readinessCmd() {
1012
1019
  // remotes (lib/workspace.mjs), packages resolve to exact commits (lib/packages.mjs,
1013
1020
  // lock v3) and the only persisted state is `oats-lock.json` beside oats-local.yaml.
1014
1021
 
1022
+ /** This command's read session (lib/remote.mjs createReadSession): one per process, created at the
1023
+ * first remote read, closed when the command ends (the `finally` of the dispatch) and, for the
1024
+ * process.exit paths, on exit — no `git cat-file --batch` child outlives the command. Its git
1025
+ * children run as their own process groups, so a terminal's Ctrl-C no longer reaches them: a
1026
+ * SIGINT, SIGTERM or SIGHUP closes the session and exits (128 + the signal number), which kills them. */
1027
+ let readSession = null;
1028
+ /** The validated `--max-age` seconds (checked once at dispatch: maxAgeRefusal), null when not given. */
1029
+ let maxAgeGiven = null;
1030
+ function commandSession() {
1031
+ if (!readSession) {
1032
+ readSession = remoteModule.createReadSession({ maxAge: maxAgeGiven ?? 0 });
1033
+ process.on("exit", () => readSession.closeNow());
1034
+ for (const [signal, code] of [["SIGINT", 130], ["SIGTERM", 143], ["SIGHUP", 129]]) process.once(signal, () => {
1035
+ readSession.closeNow();
1036
+ // Another handler (a scheduler lock's release) exits on its own after this one.
1037
+ if (process.listenerCount(signal) === 0) process.exit(code);
1038
+ });
1039
+ }
1040
+ return readSession;
1041
+ }
1042
+ /** Which kernel command forms take --max-age: THE allow-list (docs/desktop-cli-api.md "Observation reuse").
1043
+ * → null when this form reads with observation reuse, else the E_BAD_ARGS message. `head` is argv before `--`. */
1044
+ const MAX_AGE_READS = "status, workspace status, souls, capabilities, inspect --soul|--home, and the read forms of teams and soul teams";
1045
+ function maxAgeRefusal(command, head) {
1046
+ const word = (i) => (head[i] !== undefined && !head[i].startsWith("--") ? head[i] : undefined);
1047
+ const refuse = (form) => `--max-age is not accepted by \`oats ${form}\`: only the read verbs reuse observations (${MAX_AGE_READS})`;
1048
+ if (head.includes("--server")) return "--max-age cannot be combined with --server: observation reuse is local to this machine";
1049
+ switch (command) {
1050
+ case "status": case "souls": case "capabilities": case "inspect": return null;
1051
+ case "workspace": return word(1) === "status" ? null : refuse(["workspace", word(1)].filter(Boolean).join(" "));
1052
+ case "teams": return word(1) === undefined ? null : refuse(`teams ${word(1)}`);
1053
+ case "soul": {
1054
+ if (word(1) !== "teams") return refuse(["soul", word(1)].filter(Boolean).join(" "));
1055
+ const edit = ["--add", "--remove", "--default", "--clear-default"].find((f) => head.includes(f));
1056
+ return edit ? refuse(`soul teams ${edit}`) : null;
1057
+ }
1058
+ default: {
1059
+ const sub = ["package", "schedule", "session", "trigger", "automations", "launch-config", "server", "instance", "operation", "pane"].includes(command) ? word(1) : undefined;
1060
+ return refuse([command, sub].filter(Boolean).join(" "));
1061
+ }
1062
+ }
1063
+ }
1064
+ /** The observation block: the heads' oldest observedAt and reuse (the read session) and the revision of
1065
+ * the local configuration this command read (lib/local-inputs.mjs, recording since dispatch). */
1066
+ const observationBlock = () => ({ ...commandSession().observation(), localRevision: localRevision() });
1067
+ /** A read verb's JSON with the observation block — only when --max-age was given (0 included); without
1068
+ * it the document is exactly what it was before the feature (Desktop decodes closed shapes). */
1069
+ const withObservation = (doc) => (maxAgeGiven === null ? doc : { ...doc, observation: observationBlock() });
1070
+
1015
1071
  /** Remote options threaded into every remote call. OATS_REMOTE_CACHE relocates
1016
- * the content-addressed fetch cache (tests never touch ~/.cache). */
1072
+ * the content-addressed fetch cache (tests never touch ~/.cache); `session` is the command's read session. */
1017
1073
  function remoteOptionsFromEnv() {
1018
1074
  const cacheDir = process.env.OATS_REMOTE_CACHE;
1019
- return cacheDir ? { cacheDir: resolve(cacheDir) } : {};
1075
+ return { ...(cacheDir ? { cacheDir: resolve(cacheDir) } : {}), session: commandSession() };
1020
1076
  }
1021
1077
 
1022
1078
  /** The v2 deployment context at --dir: { dir, localPath, local, deploymentDir, remoteOptions }. */
@@ -1442,7 +1498,7 @@ async function workspaceCmd() {
1442
1498
  result.warnings.push({ code: "automation-trust-stale", entry, message: `oats-local.yaml automations.trust names ${entry}, which is no workspace trigger or schedule${actx.snapshot ? "" : " (no automations snapshot yet: run oats sync)"}; its member may not have synced yet` });
1443
1499
  }
1444
1500
  await workspaceStatusFacts(result, discovery, lock, ctx);
1445
- if (JSON_MODE) { jsonOk(result); return; }
1501
+ if (JSON_MODE) { jsonOk(withObservation(result)); return; }
1446
1502
  console.log(`workspace ${workspaceName(discovery)} (${discovery.key} @ ${short(discovery.commit)}) local ${shortPath(ctx.localPath)}\n`);
1447
1503
  if (standalone) console.log(` (${standaloneNote(discovery)})\n`);
1448
1504
  console.log("Members:");
@@ -1532,7 +1588,7 @@ async function teamsCmd() {
1532
1588
  else if (sub === "default") result = V.teamsDefault(ctx, label);
1533
1589
  } catch (e) { if (typeof e?.code === "string" && e.code.startsWith("E_")) return bail(e.code, e.message, e.details); throw e; }
1534
1590
  const doc = V.teamsDocument(result ? { ...ctx, local: result.local } : ctx);
1535
- if (JSON_MODE) { jsonOk(result ? { ...doc, changed: result.changed } : doc); return; }
1591
+ if (JSON_MODE) { jsonOk(result ? { ...doc, changed: result.changed } : withObservation(doc)); return; }
1536
1592
  if (result) console.log(result.changed ? `${sub === "add" ? `Declared team ${label}` : sub === "remove" ? `Removed team ${label}` : `The default team is now ${label}`} in ${shortPath(ctx.localPath)}` : "Nothing to change");
1537
1593
  console.log(`default ${doc.defaultTeam ?? "(none)"}`);
1538
1594
  if (!doc.teams.length) console.log("teams (none: `oats aweb setup` creates them, or `oats teams add <label> --team <id>`)");
@@ -1559,7 +1615,7 @@ async function soulCmd() {
1559
1615
  else {
1560
1616
  // The soul is named as for spawn: E_SOUL_UNKNOWN / E_SOUL_AMBIGUOUS; package souls come from the lock.
1561
1617
  let lock = null;
1562
- try { lock = existsSync(join(ctx.deploymentDir, LOCK_FILE)) ? readLock(ctx.deploymentDir) : null; } catch (e) { return bail(e.code || "E_LOCK_SCHEMA", e.message, e.details); }
1618
+ try { lock = readLockIfPresent(ctx.deploymentDir); } catch (e) { return bail(e.code || "E_LOCK_SCHEMA", e.message, e.details); }
1563
1619
  let discovery, entry;
1564
1620
  try {
1565
1621
  const { discoverOrStandalone, findSoulEntry } = await import("../lib/instance-resolution.mjs");
@@ -1576,7 +1632,7 @@ async function soulCmd() {
1576
1632
  if (mutating) result = V.soulTeamsEdit(teamsCtx, key, edit);
1577
1633
  doc = V.soulTeamsDocument(result ? { ...teamsCtx, local: result.local } : teamsCtx, { soul, key });
1578
1634
  } catch (e) { if (typeof e?.code === "string" && e.code.startsWith("E_")) return bail(e.code, e.message, e.details); throw e; }
1579
- if (JSON_MODE) { jsonOk(result ? { ...doc, changed: result.changed } : doc); return; }
1635
+ if (JSON_MODE) { jsonOk(result ? { ...doc, changed: result.changed } : withObservation(doc)); return; }
1580
1636
  if (result) console.log(result.changed ? `Updated the teams of ${key === "*" ? "every soul" : key} in ${shortPath(teamsCtx.localPath)}` : "Nothing to change");
1581
1637
  console.log(`${key === "*" ? "every soul" : key} on this computer: default ${doc.defaultTeam ? `${doc.defaultTeam.label} (${doc.defaultTeam.from})` : "(none)"}`);
1582
1638
  if (doc.teams.length) printTable(["team", "id", "from", "why"], doc.teams.map((t) => [t.default ? `${t.label} (default)` : t.label, t.team ?? "(no id yet)", t.from, t.via.join(",")]));
@@ -1595,7 +1651,7 @@ async function itemsCmd(kind) {
1595
1651
  if (kind === "capabilities") await capabilityFacts(items, discovery, lock, ctx, remote);
1596
1652
  if (kind === "souls") await soulFacts(items, discovery, lock, ctx, remote);
1597
1653
  const standalone = discovery.standalone === true;
1598
- if (JSON_MODE) { jsonOk({ [`${kind}Api`]: 1, standalone: standalone || undefined, workspace: { name: workspaceName(discovery), key: discovery.key, commit: discovery.commit }, [kind]: items, problems: discovery.problems }); return; }
1654
+ if (JSON_MODE) { jsonOk(withObservation({ [`${kind}Api`]: 1, standalone: standalone || undefined, workspace: { name: workspaceName(discovery), key: discovery.key, commit: discovery.commit }, [kind]: items, problems: discovery.problems })); return; }
1599
1655
  console.log(`${kind} of workspace ${workspaceName(discovery)} (${discovery.key} @ ${short(discovery.commit)})${standalone ? ` — ${standaloneNote(discovery)}` : ""}\n`);
1600
1656
  if (!items.length) console.log(" (none)");
1601
1657
  else if (kind === "souls") printTable(["name", "origin", "teams here", "work"], items.map((s) => [s.name, s.origin, s.teams === null ? "(invalid: oats teams)" : s.teams.map((t) => (t.default ? `${t.label}*` : t.label)).join(",") || "—", s.work ?? "—"]));
@@ -1615,14 +1671,24 @@ async function capabilityFacts(rows, discovery, lock, ctx, remote = remoteModule
1615
1671
  for (const [id, entry] of Object.entries(lock?.packages || {})) {
1616
1672
  try { packages.set(id, await lockedPackageCapabilities(id, entry, { catalog, remote, remoteOptions })); } catch { packages.set(id, null); }
1617
1673
  }
1674
+ // Indexes built once (a scan of every member per row was O(rows × members)): the member row per key
1675
+ // (memberRowByKey); the member rows per (key, commit), in row order.
1676
+ const capOf = (member, name) => member?.capabilities.find((c) => c.name === name);
1677
+ const rowsAt = new Map();
1678
+ for (const r of rows) {
1679
+ if (r.kind === "package") continue;
1680
+ const k = `${r.repoKey}\0${r.commit}`;
1681
+ if (!rowsAt.has(k)) rowsAt.set(k, []);
1682
+ rowsAt.get(k).push(r);
1683
+ }
1618
1684
  // A member capability's fingerprint is its Git tree at the commit (one listing per member commit); a
1619
1685
  // package's is the package integrity (the lock).
1620
1686
  const trees = new Map();
1621
1687
  const treeOf = async ({ repoKey, commit }, ref, dir) => {
1622
1688
  const key = `${repoKey}\0${commit}`;
1623
1689
  if (!trees.has(key)) {
1624
- const member = discovery.members.find((m) => m.key === repoKey);
1625
- const dirs = rows.filter((r) => r.kind !== "package" && r.repoKey === repoKey && r.commit === commit).map((r) => member?.capabilities.find((c) => c.name === r.name)?.path).filter(Boolean);
1690
+ const member = memberRowByKey(discovery.members, repoKey);
1691
+ const dirs = (rowsAt.get(key) || []).map((r) => capOf(member, r.name)?.path).filter(Boolean);
1626
1692
  trees.set(key, remoteModule.remoteTreeOids(ref, commit, [...new Set(dirs)], remoteOptions).catch(() => new Map()));
1627
1693
  }
1628
1694
  return (await trees.get(key)).get(dir) ?? null;
@@ -1633,7 +1699,7 @@ async function capabilityFacts(rows, discovery, lock, ctx, remote = remoteModule
1633
1699
  const read = packages.get(row.package), cap = read?.capabilities.find((c) => c.name === row.name);
1634
1700
  if (cap) ({ manifest, dir } = cap), ref = read.ref;
1635
1701
  } else {
1636
- const cap = discovery.members.find((m) => m.key === row.repoKey)?.capabilities.find((c) => c.name === row.name);
1702
+ const cap = capOf(memberRowByKey(discovery.members, row.repoKey), row.name);
1637
1703
  if (cap) { manifest = cap.manifest; dir = cap.path; ref = memberRef(discovery, remoteModule, row.repoKey); }
1638
1704
  }
1639
1705
  const provides = manifest ? await capabilityProvides({ ref, commit: row.commit, dir, manifest, remote, remoteOptions }) : { skills: null, commands: null, hooks: null };
@@ -1648,9 +1714,13 @@ async function capabilityFacts(rows, discovery, lock, ctx, remote = remoteModule
1648
1714
  * discovery and the lock without spawning (soulSpawnability). */
1649
1715
  async function soulFacts(rows, discovery, lock, ctx, remote = remoteModule) {
1650
1716
  const { soulSpawnability } = await import("../lib/instance-resolution.mjs");
1651
- const entryOf = (row) => row.kind === "package" ? (discovery.packageSouls || []).find((p) => p.qualifiedName === row.qualifiedName)
1652
- : row.kind === "external" ? (discovery.external || []).map((e) => e.soul).find((x) => x.name === row.name && x.repoKey === row.repoKey)
1653
- : discovery.members.find((m) => m.key === row.repoKey)?.souls.find((x) => x.name === row.name);
1717
+ // Indexes built once, each keeping the FIRST match as `find` did (a scan per row was O(souls × members)).
1718
+ const first = (pairs) => { const m = new Map(); for (const [k, v] of pairs) if (!m.has(k)) m.set(k, v); return m; };
1719
+ const packageSoul = first((discovery.packageSouls || []).map((p) => [p.qualifiedName, p]));
1720
+ const externalSoul = first((discovery.external || []).map((e) => e.soul).map((x) => [`${x.name}\0${x.repoKey}`, x]));
1721
+ const entryOf = (row) => row.kind === "package" ? packageSoul.get(row.qualifiedName)
1722
+ : row.kind === "external" ? externalSoul.get(`${row.name}\0${row.repoKey}`)
1723
+ : memberRowByKey(discovery.members, row.repoKey)?.souls.find((x) => x.name === row.name);
1654
1724
  for (const row of rows) {
1655
1725
  const entry = entryOf(row);
1656
1726
  row.file = { path: `${row.path}/soul.yaml`, url: remoteModule.browseUrl(row.repoKey, row.commit, `${row.path}/soul.yaml`) };
@@ -1688,7 +1758,7 @@ async function statusDrift(data) {
1688
1758
  if (!anything) return { drift: new Map(), soul: new Map(), souls, unreachable: null };
1689
1759
  const deploymentDir = dirname(ctx.path);
1690
1760
  let lock = null;
1691
- try { if (existsSync(join(deploymentDir, LOCK_FILE))) lock = readLock(deploymentDir); } catch { lock = null; }
1761
+ try { lock = readLockIfPresent(deploymentDir); } catch { lock = null; }
1692
1762
  let discovery;
1693
1763
  // The standalone view (decisions 10/25) is a discovery too: drift of a standalone
1694
1764
  // instance is computed against its member's current state, not reported "unreachable".
@@ -1706,9 +1776,12 @@ async function statusDrift(data) {
1706
1776
  if (hasSoul(i)) { try { const row = soulDriftOf(i, discovery); if (row) soul.set(key, row); } catch { /* an unreadable soul record shows as no soul row */ } }
1707
1777
  }
1708
1778
  // The roster's soul row reflects the CURRENT member commit too (the pointer may lag a moved member).
1779
+ // First match per key, as `find` gave, from indexes built once (not a scan per agent).
1780
+ const packageSoulAt = new Map();
1781
+ for (const p of discovery.packageSouls || []) { const k = `${p.package}\0${p.path}`; if (!packageSoulAt.has(k)) packageSoulAt.set(k, p); }
1709
1782
  for (const [, stamp] of souls) {
1710
- if (typeof stamp.package === "string") { const now = (discovery.packageSouls || []).find((p) => p.package === stamp.package && p.path === stamp.path); if (now) stamp.current = now.commit; continue; }
1711
- const member = discovery.members.find((m) => m && m.key === stamp.repoKey);
1783
+ if (typeof stamp.package === "string") { const now = packageSoulAt.get(`${stamp.package}\0${stamp.path}`); if (now) stamp.current = now.commit; continue; }
1784
+ const member = memberRowByKey(discovery.members, stamp.repoKey);
1712
1785
  if (member && typeof member.commit === "string" && (member.confirmed || (discovery.standalone === true && member.key === discovery.key))) stamp.current = member.commit;
1713
1786
  }
1714
1787
  return { drift, soul, souls, unreachable: null };
@@ -1779,7 +1852,8 @@ async function status() {
1779
1852
  if (s) i.soul = { repoKey: s.repoKey, commit: s.commit, current: s.current?.commit ?? null, status: s.status, ...(s.reason ? { reason: s.reason } : {}), ...(s.package ? { package: s.package, version: s.version, currentVersion: s.current?.version ?? null } : {}) };
1780
1853
  }
1781
1854
  }
1782
- console.log(JSON.stringify({ root, agents: data, ...(ws ? { workspace: ws.unreachable ? { reachable: false, ...ws.unreachable } : { reachable: true } } : {}), ...(problems.length ? { problems } : {}), ...envelopeWarnings() }, null, 2)); return;
1855
+ const observation = maxAgeGiven === null ? {} : { observation: observationBlock() };
1856
+ console.log(JSON.stringify({ root, agents: data, ...observation, ...(ws ? { workspace: ws.unreachable ? { reachable: false, ...ws.unreachable } : { reachable: true } } : {}), ...(problems.length ? { problems } : {}), ...envelopeWarnings() }, null, 2)); return;
1783
1857
  }
1784
1858
  console.log(`oats status — agents root ${shortPath(root)}\n`);
1785
1859
  if (ws?.unreachable) console.log(` workspace: unreachable (${ws.unreachable.reason}) — drift unknown\n`);
@@ -1789,7 +1863,7 @@ async function status() {
1789
1863
  console.log(` ${a.name} [work: ${a.work || "checkout"}, repo: ${soulRepoLabel(a, ws)}]`);
1790
1864
  if (a.description) console.log(` ${a.description}`);
1791
1865
  for (const i of a.instances) {
1792
- console.log(` • ${i.instance} ${i.retirePending ? "RETIRING" : i.running ? "RUNNING" : "idle"} (branch ${i.branch || "?"}, ${i.work || "?"})`);
1866
+ console.log(` • ${i.instance} ${i.retirePending ? "RETIRING" : livenessWord(i)} (branch ${i.branch || "?"}, ${i.work || "?"})${i.runtimeError ? ` ${i.runtimeError}` : ""}`);
1793
1867
  const key = i.home ?? `${a.name}/${i.instance}`;
1794
1868
  if (i.identity) console.log(` identity: ${servedIdentityLine(i.identity)}`);
1795
1869
  // The kernel the home's plain `oats` runs (its last launch's), when it is not this one.
@@ -1806,10 +1880,12 @@ async function status() {
1806
1880
  }
1807
1881
  }
1808
1882
 
1883
+ /** A status row's liveness in text: `running` null (a server that cannot be read, a Herdr home) is unknown, never idle. */
1884
+ const livenessWord = (i) => i.running === true ? "RUNNING" : i.running === false ? "idle" : "unknown";
1809
1885
  /** The flags `oats spawn` reads: those taking a value, and switches. `--provider` takes two words.
1810
1886
  * `--instance` is refused by a local spawn (with its replacement) but still travels to an older
1811
1887
  * 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"]);
1888
+ 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
1889
  const SPAWN_SWITCHES = new Set(["allow-child-spawns", "json", "no-child-spawns", "no-launch", "no-yolo", "preview", "yolo"]);
1814
1890
  /** Why `argv` (after `spawn`, the soul first) is not a spawn, or undefined: a positional after the
1815
1891
  * soul or a flag spawn does not read is never ignored. A value flag consumes its value exactly as
@@ -1831,13 +1907,20 @@ function spawnArgvProblem(argv) {
1831
1907
  }
1832
1908
  return undefined;
1833
1909
  }
1910
+ /** The Herdr spawn flags (removed in 0.31.0), named for the refusal; undefined when none is given. */
1911
+ function herdrSpawnFlag() {
1912
+ if (flag("backend") === "herdr") return "--backend herdr";
1913
+ if (flag("herdr-socket") !== undefined) return "--herdr-socket";
1914
+ return undefined;
1915
+ }
1834
1916
  async function spawnCmd() {
1835
1917
  // JSON mode: contract envelope, stable error codes, stderr-only progress.
1836
1918
  const bail = (code, msg, details) => (JSON_MODE ? jsonFail(code, msg, details) : die(msg));
1837
1919
  const note = (msg) => (JSON_MODE ? console.error(msg) : console.log(msg));
1838
1920
  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");
1921
+ { const herdr = herdrSpawnFlag(); if (herdr) { const e = herdrSettingRemoved(`${herdr} was given`); bail(e.code, e.message); } }
1922
+ const backend = valueFlag("backend");
1923
+ if (backend !== undefined && backend !== "tmux") bail("E_BAD_ARGS", "--backend must be tmux");
1841
1924
  const requestedWork = valueFlag("work");
1842
1925
  const workDir = valueFlag("work-dir"), branch = valueFlag("branch"), repo = valueFlag("repo");
1843
1926
  const checkDirectoryOptions = (work) => {
@@ -1845,7 +1928,7 @@ async function spawnCmd() {
1845
1928
  };
1846
1929
  checkDirectoryOptions(requestedWork); // before anything is resolved or written
1847
1930
  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]");
1931
+ 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
1932
  // Retired boundary flags (maintainer transport ruling): fail LOUDLY before
1850
1933
  // ANY side effect, including root discovery.
1851
1934
  // Local souls (local-agents/) are gone with the workspace model: a soul is a member
@@ -2039,7 +2122,7 @@ async function spawnCmd() {
2039
2122
  // An attached instance's repository is its work tree owner's (derived by the kernel).
2040
2123
  repo: preparedRepo !== undefined ? preparedRepo : ["directory", "attached"].includes(requestedWork || agent.work)
2041
2124
  ? repo : repo || defaultRepo(workspaceOf(root)) || defaultRepo(process.cwd()),
2042
- work: requestedWork, workDir, harness: harnessFlag(), backend, herdrSocket, yolo, model: flag("model"), branch,
2125
+ work: requestedWork, workDir, harness: harnessFlag(), backend, yolo, model: flag("model"), branch,
2043
2126
  launchConfig: valueFlag("launch-config"),
2044
2127
  launch: !args.includes("--no-launch"),
2045
2128
  ...(triggerEvent ? { triggerEvent } : {}),
@@ -2059,7 +2142,7 @@ async function spawnCmd() {
2059
2142
  // (the deployment's cache had no entry for its commit): the result says so.
2060
2143
  if (prepared) r.soulFetched = soulFetched;
2061
2144
  if (JSON_MODE) { jsonOk(r); return; }
2062
- console.log(`preview ${r.agent} → ${r.instance} (${r.work}${r.branch ? `, branch ${r.branch} from ${r.base.ref}@${r.base.oid.slice(0, 12)}` : ""}) harness ${r.harness}${r.model ? ` model ${r.model}` : ` (${r.modelSource})`}; nothing was created${soulFetched ? " (the soul source was fetched to a temporary copy, not kept)" : ""}`);
2145
+ console.log(`preview ${r.agent} → ${r.instance} (${r.work}${r.branch ? `, branch ${r.branch} from ${r.base.ref}@${r.base.oid.slice(0, 12)}` : ""}) harness ${r.harness}${r.model ? ` model ${r.model}` : ` (${r.modelSource})`}${r.launchConfig ? ` via launch configuration ${r.launchConfig}${r.launchConfigDefault ? ` (this machine's ${r.harness} default)` : ""}` : ""}${r.yolo ? " YOLO" : ""}; nothing was created${soulFetched ? " (the soul source was fetched to a temporary copy, not kept)" : ""}`);
2063
2146
  return;
2064
2147
  }
2065
2148
  } catch (e) {
@@ -2071,7 +2154,7 @@ async function spawnCmd() {
2071
2154
  // A launch refusal (configuration, executable, environment reference,
2072
2155
  // model, harness) is a fact about the selection, not a spawn-mechanism
2073
2156
  // failure: it keeps its own code so a GUI can act on it.
2074
- if (typeof e?.code === "string" && /^E_LAUNCH_|^E_MODEL_UNKNOWN$|^E_UNSUPPORTED_HARNESS$|^E_HARNESS_UNAVAILABLE$/.test(e.code)) { bail(e.code, e.message, e.details); throw e; }
2157
+ if (typeof e?.code === "string" && /^E_LAUNCH_|^E_MODEL_UNKNOWN$|^E_UNSUPPORTED_HARNESS$|^E_HARNESS_UNAVAILABLE$|^E_CLAUDE_CONFIG_REMOVED$/.test(e.code)) { bail(e.code, e.message, e.details); throw e; }
2075
2158
  // An unmet declared requirement is a fact about the soul's configuration
2076
2159
  // (with a remedy), not a spawn-mechanism failure: keep its code and details.
2077
2160
  if (e?.code === "E_REQUIREMENT_INACTIVE") { bail(e.code, e.message, { soul: e.soul, capabilities: e.capabilities, context: e.context, remedy: e.remedy }); throw e; }
@@ -2106,11 +2189,10 @@ async function spawnCmd() {
2106
2189
  instance: r.instance, agent: r.agent, home: r.home, work: r.work,
2107
2190
  branch: r.branch || null, launched: r.launched, warnings: r.warnings || [],
2108
2191
  ...(wakeSchedule ? { wakeSchedule } : {}), ...(wakeScheduleError ? { wakeScheduleError } : {}),
2109
- tmux: r.tmux || null, repo: r.repo || null, harness: r.harness || null,
2192
+ tmux: r.tmux || null, backend: "tmux", repo: r.repo || null, harness: r.harness || null,
2110
2193
  model: r.model || null, parent: r.parentInstance || null,
2111
2194
  sibling: r.siblingInstance || null, relation: r.relation || null,
2112
2195
  spawnOrigin: r.spawnOrigin, attach: r.attach,
2113
- ...(r.sessionTarget ? { sessionTarget: r.sessionTarget } : {}),
2114
2196
  ...(r.yolo !== undefined ? { yolo: r.yolo } : {}),
2115
2197
  // K6b/K6c: what bound this spawn, and whether this receipt is a replay of an earlier one.
2116
2198
  ...(r.decision ? { decision: r.decision } : {}), ...(r.replayed !== undefined ? { replayed: r.replayed } : {}),
@@ -2119,7 +2201,7 @@ async function spawnCmd() {
2119
2201
  });
2120
2202
  return;
2121
2203
  }
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"}`);
2204
+ console.log(`Spawned ${r.instance} (${r.work}${r.branch ? `, branch ${r.branch}` : ""})${r.launched ? ` — tmux window "${r.tmux.window}"` : " — not launched"}`);
2123
2205
  console.log(` home: ${shortPath(r.home)}`);
2124
2206
  if (wakeSchedule) console.log(` wake: schedule ${wakeSchedule.id} (${wakeSchedule.cron} ${wakeSchedule.tz}), next ${wakeSchedule.nextRun || "disabled"}`);
2125
2207
  if (wakeScheduleError) console.error(` wake: NOT saved — ${wakeScheduleError.message} (the instance is created and launched; add the wake by hand with oats schedule add)`);
@@ -2773,6 +2855,7 @@ async function capabilityCommand() {
2773
2855
  // OATS_SOUL is the recorded soul or nothing: an ambient value inherited from the
2774
2856
  // invoking process names some other soul (a coordinator's own), never this one.
2775
2857
  const { OATS_SOUL: _ambientSoul, ...inherited } = process.env;
2858
+ await readSession?.closeBatches(); // no idle `git cat-file --batch` child held for the provider's whole run
2776
2859
  const r = spawnSync("node", [abs, ...rest, ...rawArgs.slice(2)], { stdio: "inherit", env: {
2777
2860
  ...inherited, OATS_CAPABILITY: m.capability,
2778
2861
  // Package-runtime boundary: dispatched commands receive the active
@@ -2794,7 +2877,9 @@ async function capabilityCommand() {
2794
2877
  } });
2795
2878
  // Child never ran (spawn error): nothing reached stdout — keep the envelope contract.
2796
2879
  if (r.error) bail("E_CAPABILITY_BROKEN", `oats ${cmd} ${sub}: ${r.error.message || r.error}`);
2797
- process.exit(r.status ?? 1);
2880
+ // A provider killed by a signal (a Ctrl-C reaches both it and us; our read session's handler
2881
+ // waits for spawnSync) exits as the shell reports a signal death: 128 + its number.
2882
+ process.exit(r.status ?? (r.signal ? 128 + (osConstants.signals[r.signal] ?? 0) : 1));
2798
2883
  }
2799
2884
  }
2800
2885
 
@@ -2852,7 +2937,7 @@ function versionCmd() {
2852
2937
  // Phase B: `instance-modules` and `spawn-provider-payload` are advertised only once spawn
2853
2938
  // runs on resolve/materialize (contract §6); a feature the binary does not implement is
2854
2939
  // 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 }));
2940
+ 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", "observe-max-age", "launch-config-default"], 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
2941
  return;
2857
2942
  }
2858
2943
  console.log(`@awebai/oats ${OATS_VERSION} (desktop API v1)`);
@@ -2896,9 +2981,9 @@ async function experimentalCmd() {
2896
2981
  * an OpenSSH host alias, the remote workspace, the remote oats path. Keys
2897
2982
  * and passwords never enter it; ssh owns those. */
2898
2983
  function serverCmd() {
2899
- const bail = (code, msg) => (JSON_MODE ? jsonFail(code, msg) : die(msg));
2984
+ const bail = (code, msg, details) => (JSON_MODE ? jsonFail(code, msg, details) : die(msg));
2900
2985
  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]";
2986
+ 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
2987
  if (!["add", "list", "remove", "check", "roster", "forget"].includes(sub)) bail("E_USAGE", usage);
2903
2988
  if (sub === "forget") {
2904
2989
  // A saved route whose remote instance is gone can be dropped only by
@@ -2939,15 +3024,16 @@ function serverCmd() {
2939
3024
  const rows = Object.entries(servers).map(([id, s]) => ({ id, ...s, target: targetOf({ id, ...s }), snapshots: listSnapshots(id).length }));
2940
3025
  if (JSON_MODE) { jsonOk({ file: SERVERS_FILE(), servers: rows }); return; }
2941
3026
  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)` : ""}`);
3027
+ 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
3028
  return;
2944
3029
  }
2945
3030
  const id = args[2];
2946
3031
  if (!id || id.startsWith("--")) bail("E_USAGE", usage);
2947
3032
  if (sub === "add") {
2948
3033
  const val = (name) => { const v = flag(name); return v === true ? bail("E_BAD_ARGS", `--${name} needs a value`) : v; };
3034
+ if (flag("herdr") !== undefined) { const e = herdrSettingRemoved("server add --herdr was given"); bail(e.code, e.message); }
2949
3035
  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; }
3036
+ for (const [k, f] of [["oatsPath", "oats"], ["path", "path"], ["label", "label"]]) { const v = val(f); if (v !== undefined) entry[k] = v; }
2951
3037
  if (!entry.sshHost || !entry.workspace) bail("E_USAGE", usage);
2952
3038
  try { validateServer(id, entry); } catch (e) { bail(e.code, e.message); }
2953
3039
  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 +3056,30 @@ function serverCmd() {
2970
3056
  let server; try { server = getServer(id); } catch (e) { bail(e.code, e.message); }
2971
3057
  const target = targetOf(server);
2972
3058
  try {
2973
- const remote = checkRemote(target);
3059
+ const remote = checkRemote(target, { serverId: id });
2974
3060
  const status = routeCommand(id, "status", [], { server });
2975
3061
  const agents = status.envelope.ok ? (status.envelope.result.agents || []).length : undefined;
2976
3062
  if (JSON_MODE) { jsonOk({ id, target, remote, workspaceReachable: !!status.envelope.ok, agents, error: status.envelope.ok ? undefined : status.envelope.error }); return; }
2977
3063
  console.log(`${id}: ssh ${target.sshHost} ok, remote oats ${remote.version} (envelope v${remote.schemaVersion})`);
2978
3064
  console.log(status.envelope.ok ? ` workspace ${target.workspace}: ${agents} agent(s)` : ` workspace ${target.workspace}: ${status.envelope.error?.message || "not usable"}`);
2979
3065
  if (!status.envelope.ok) process.exit(1);
2980
- } catch (e) { bail(e.code || "E_SSH", e.message); }
3066
+ } catch (e) { bail(e.code || "E_SSH", e.message, e.details); }
2981
3067
  }
2982
3068
 
2983
3069
  /** `oats <spawn|retire|status> --server <id> ...`: run the command on the
2984
3070
  * registered server's installed oats, same arguments, same envelope. The
2985
3071
  * local side only routes and keeps the route snapshot per remote instance. */
2986
3072
  async function serverRouteCmd() {
2987
- const bail = (code, msg) => (JSON_MODE ? jsonFail(code, msg) : die(msg));
3073
+ const bail = (code, msg, details, exit) => (JSON_MODE ? jsonFail(code, msg, details, exit) : die(msg, exit));
2988
3074
  const id = flag("server");
2989
3075
  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);
3076
+ // The operations contract, launch-config and the host's reads address an
3077
+ // exact member context on the host, so their explicit --dir travels, as
3078
+ // does a retire plan's or guarded apply's (the lifecycle contract's own
3079
+ // arguments); every other routed command takes its scope from the
3080
+ // registration.
3081
+ const explicitScopeOk = ["inspect", "operation", "launch-config", "readiness", "instance"].includes(cmd)
3082
+ || (cmd === "retire" && ["--plan", "--plan-revision", "--idempotency-key"].some((f) => args.includes(f)));
2994
3083
  if (!explicitScopeOk && flag("dir") !== undefined) bail("E_BAD_ARGS", "--dir cannot be combined with --server: the remote workspace comes from the server registration");
2995
3084
  if (cmd === "launch-config") {
2996
3085
  const action = args[1];
@@ -3014,7 +3103,7 @@ async function serverRouteCmd() {
3014
3103
  options.keepEnv = args.includes("--keep-env");
3015
3104
  }
3016
3105
  let out;
3017
- try { out = launchConfigRemote(id, options); } catch (e) { bail(e.code || "E_SSH", e.message); }
3106
+ try { out = launchConfigRemote(id, options); } catch (e) { bail(e.code || "E_SSH", e.message, e.details); }
3018
3107
  if (out.stderr?.trim()) process.stderr.write(out.stderr.endsWith("\n") ? out.stderr : out.stderr + "\n");
3019
3108
  if (JSON_MODE) { console.log(JSON.stringify(withLocalWarnings(out.envelope), null, 2)); if (!out.envelope.ok) process.exit(1); return; }
3020
3109
  if (!out.envelope.ok) die(`${id}: ${out.envelope.error?.message || "launch configuration request failed"} (${out.envelope.error?.code || "E_REMOTE"})`);
@@ -3031,7 +3120,7 @@ async function serverRouteCmd() {
3031
3120
  const inst = flag("instance");
3032
3121
  if (!inst || inst === true) bail("E_BAD_ARGS", "okf harvest --server needs --instance <name> (spawned from here)");
3033
3122
  let routed;
3034
- try { routed = routeCommand(id, "harvest", [inst]); } catch (e) { bail(e.code || "E_SSH", e.message); }
3123
+ try { routed = routeCommand(id, "harvest", [inst]); } catch (e) { bail(e.code || "E_SSH", e.message, e.details); }
3035
3124
  if (routed.stderr?.trim()) process.stderr.write(routed.stderr.endsWith("\n") ? routed.stderr : routed.stderr + "\n");
3036
3125
  if (JSON_MODE) { console.log(JSON.stringify(withLocalWarnings(routed.envelope), null, 2)); if (!routed.envelope.ok) process.exit(1); return; }
3037
3126
  if (!routed.envelope.ok) die(`${id}: ${routed.envelope.error?.message || "harvest failed"} (${routed.envelope.error?.code || "E_REMOTE"})`);
@@ -3059,7 +3148,7 @@ async function serverRouteCmd() {
3059
3148
  rest.push(a);
3060
3149
  }
3061
3150
  let out;
3062
- try { out = scheduleRemote(id, rest); } catch (e) { bail(e.code || "E_SSH", e.message); }
3151
+ try { out = scheduleRemote(id, rest); } catch (e) { bail(e.code || "E_SSH", e.message, e.details); }
3063
3152
  if (out.stderr?.trim()) process.stderr.write(out.stderr.endsWith("\n") ? out.stderr : out.stderr + "\n");
3064
3153
  if (JSON_MODE) { console.log(JSON.stringify(withLocalWarnings(out.envelope), null, 2)); if (!out.envelope.ok) process.exit(1); return; }
3065
3154
  if (!out.envelope.ok) die(`${id}: ${out.envelope.error?.message || "schedule command failed"} (${out.envelope.error?.code || "E_REMOTE"})`);
@@ -3072,7 +3161,7 @@ async function serverRouteCmd() {
3072
3161
  // Desktop preflight before a remote attach: the execution host's own
3073
3162
  // inspect, relayed as its envelope; a failure is a failure, nonzero.
3074
3163
  let out;
3075
- try { out = inspectRemote(id, addr); } catch (e) { bail(e.code || "E_SSH", e.message); }
3164
+ try { out = inspectRemote(id, addr); } catch (e) { bail(e.code || "E_SSH", e.message, e.details); }
3076
3165
  if (out.stderr?.trim()) process.stderr.write(out.stderr.endsWith("\n") ? out.stderr : out.stderr + "\n");
3077
3166
  if (JSON_MODE) { console.log(JSON.stringify(withLocalWarnings(out.envelope), null, 2)); if (!out.envelope.ok) process.exit(1); return; }
3078
3167
  if (!out.envelope.ok) die(`${id}: ${out.envelope.error?.message || "inspect failed"} (${out.envelope.error?.code || "E_REMOTE"})`);
@@ -3085,7 +3174,7 @@ async function serverRouteCmd() {
3085
3174
  const choices = { ...addr, model: value("model"), launchConfig: value("launch-config"), harness: harnessFlag(value), yolo: yoloFlag() };
3086
3175
  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
3176
  let out;
3088
- try { out = (args[1] === "restart" ? restartRemote : startRemote)(id, choices); } catch (e) { bail(e.code || "E_SSH", e.message); }
3177
+ try { out = (args[1] === "restart" ? restartRemote : startRemote)(id, choices); } catch (e) { bail(e.code || "E_SSH", e.message, e.details); }
3089
3178
  if (out.stderr?.trim()) process.stderr.write(out.stderr.endsWith("\n") ? out.stderr : out.stderr + "\n");
3090
3179
  if (JSON_MODE) { console.log(JSON.stringify(withLocalWarnings(out.envelope), null, 2)); if (!out.envelope.ok) process.exit(1); return; }
3091
3180
  if (!out.envelope.ok) die(`${id}: ${out.envelope.error?.message || "start failed"} (${out.envelope.error?.code || "E_REMOTE"})`);
@@ -3099,7 +3188,7 @@ async function serverRouteCmd() {
3099
3188
  const file = flag("file");
3100
3189
  if (!file || file === true) bail("E_BAD_ARGS", "session upload needs --file <local path>");
3101
3190
  let r;
3102
- try { r = uploadAttachment({ file, server: id, ...addr }); } catch (e) { bail(e.code || "E_UPLOAD_FAILED", e.message); }
3191
+ try { r = uploadAttachment({ file, server: id, ...addr }); } catch (e) { bail(e.code || "E_UPLOAD_FAILED", e.message, e.details); }
3103
3192
  if (r.stderr) process.stderr.write(r.stderr + "\n");
3104
3193
  if (JSON_MODE) { jsonOk(r); return; }
3105
3194
  console.log(`Uploaded ${r.name} (${r.bytes} bytes) to ${r.instance || r.home} on ${id}: ${r.path}`);
@@ -3108,13 +3197,22 @@ async function serverRouteCmd() {
3108
3197
  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
3198
  let route;
3110
3199
  try { route = attachArgv(id, addr, { skipVersionCheck: args.includes("--print") }); }
3111
- catch (e) { bail(e.code || "E_BAD_ARGS", e.message); }
3200
+ // ssh failing before the viewer (the version probe, a name resolved through the host's roster)
3201
+ // is the failure ssh has under it: exit 255, on which a caller reconnects. Every other refusal,
3202
+ // and an ssh that never started (no link can come back), exits 1.
3203
+ catch (e) { bail(e.code || "E_BAD_ARGS", e.message, e.details, e.code === "E_SSH" && e.details?.sshStarted !== false ? 255 : 1); }
3112
3204
  if (args.includes("--print")) { console.log(route.argv.map(shellQuote).join(" ")); return; }
3113
3205
  const r = spawnSyncProc(route.argv[0], route.argv.slice(1), { stdio: "inherit" });
3206
+ // ssh exits 255 for its own failures: a link that died under the viewer
3207
+ // (keepalives unanswered, the master or the host's sshd gone), or one
3208
+ // never made. Say what is known rather than ending silently.
3209
+ 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
3210
  process.exit(r.status ?? 1);
3115
3211
  }
3116
3212
  // A spawn's argv is checked here, before the server is contacted.
3117
3213
  if (cmd === "spawn") {
3214
+ const herdr = herdrSpawnFlag();
3215
+ if (herdr) { const e = herdrSettingRemoved(`${herdr} was given`); bail(e.code, e.message); }
3118
3216
  const local = args.slice(1).filter((a, i, all) => a !== "--server" && all[i - 1] !== "--server");
3119
3217
  const problem = spawnArgvProblem(local);
3120
3218
  if (problem) bail("E_BAD_ARGS", problem);
@@ -3156,9 +3254,16 @@ async function serverRouteCmd() {
3156
3254
  }
3157
3255
  let routed;
3158
3256
  try { routed = routeCommand(id, cmd, rest); }
3159
- catch (e) { bail(e.code || "E_SSH", e.message); }
3257
+ catch (e) { bail(e.code || "E_SSH", e.message, e.details); }
3160
3258
  const { envelope, stderr } = routed;
3161
3259
  if (stderr && stderr.trim()) process.stderr.write(stderr.endsWith("\n") ? stderr : stderr + "\n");
3260
+ // The host's own reads and plans: its envelope, relayed unchanged.
3261
+ if (cmd === "readiness" || cmd === "instance" || (cmd === "retire" && rest.includes("--plan"))) {
3262
+ if (JSON_MODE) { console.log(JSON.stringify(withLocalWarnings(envelope), null, 2)); if (!envelope.ok) process.exit(1); return; }
3263
+ if (!envelope.ok) die(`${id}: ${envelope.error?.message || "remote command failed"} (${envelope.error?.code || "E_REMOTE"})`);
3264
+ console.log(JSON.stringify(envelope.result, null, 2));
3265
+ return;
3266
+ }
3162
3267
  if (JSON_MODE) { console.log(JSON.stringify(withLocalWarnings(envelope), null, 2)); if (!envelope.ok || envelope.result?.rollbackIncomplete) process.exit(1); return; }
3163
3268
  if (!envelope.ok && !(cmd === "retire" && envelope.result)) die(`${id}: ${envelope.error?.message || "remote command failed"} (${envelope.error?.code || "E_REMOTE"})`);
3164
3269
  const r = envelope.result;
@@ -3188,7 +3293,7 @@ async function serverRouteCmd() {
3188
3293
  console.log(`oats status — server ${id} (ssh ${r.target.sshHost}, workspace ${r.target.workspace})\n`);
3189
3294
  for (const a of r.agents || []) {
3190
3295
  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"}`);
3296
+ for (const i of a.instances || []) console.log(` • ${i.instance} ${i.retirePending ? "RETIRING" : livenessWord(i)}${i.runtimeError ? ` ${i.runtimeError}` : ""}`);
3192
3297
  }
3193
3298
  const snaps = r.snapshots || [];
3194
3299
  if (snaps.length) console.log(`\n spawned from this machine: ${snaps.map((s) => s.instance).join(", ")}`);
@@ -3224,6 +3329,17 @@ try {
3224
3329
  const selector = head.find((a) => /^--(deployment|resolution|artifact-set)$/.test(a));
3225
3330
  const refuse = (message, details) => { if (JSON_MODE) jsonFail("E_UNSUPPORTED_MODE", message, details); die(message); };
3226
3331
  if (selector) refuse(`${selector}: a captured selector is refused (the captured/portable path was removed in 0.26); run the command in its workspace deployment or instance home instead`, { selector });
3332
+ // --max-age (feature observe-max-age): ONE allow-list for every kernel command, here — never a
3333
+ // per-command copy. A capability namespace's argv is its provider's.
3334
+ if (kernelArgv && head.includes("--max-age")) {
3335
+ const refusal = maxAgeRefusal(cmd, head);
3336
+ if (refusal) cmdFail("E_BAD_ARGS", refusal);
3337
+ const raw = flag("max-age");
3338
+ if (raw === true) cmdFail("E_BAD_ARGS", `--max-age needs a value: whole seconds from 0 to ${remoteModule.MAX_AGE_LIMIT}`);
3339
+ if (!/^\d{1,5}$/.test(raw) || Number(raw) > remoteModule.MAX_AGE_LIMIT) cmdFail("E_BAD_ARGS", `--max-age takes whole seconds from 0 to ${remoteModule.MAX_AGE_LIMIT}, got ${JSON.stringify(raw)}`);
3340
+ maxAgeGiven = Number(raw);
3341
+ activateLocalInputs(); // observation.localRevision: every local config read from here on is recorded
3342
+ }
3227
3343
  const inherited = ["OATS_RESOLUTION", "OATS_DEPLOYMENT"].filter((k) => process.env[k]);
3228
3344
  if (inherited.length && cmd !== "version") refuse(`this environment carries a captured context (${inherited.join(", ")}): the captured/portable path was removed in 0.26, and nothing is run against the current context in its place — retire the captured home and re-spawn it from the deployment`, { inherited });
3229
3345
  if (cmd === "inspect" && head.includes("--request")) {
@@ -3300,7 +3416,8 @@ else if (cmd && !cmd.startsWith("--") && !HELP_WORDS.has(cmd) && JSON_MODE) json
3300
3416
  else {
3301
3417
  if (cmd && !HELP_WORDS.has(cmd) && !cmd.startsWith("--")) console.error(`oats: unknown command "${cmd}" — no kernel subcommand or active capability namespace matches\n`);
3302
3418
  console.log(usageText());
3303
- process.exit(cmd && !HELP_WORDS.has(cmd) ? 1 : 0);
3419
+ // exitCode, not exit(): the usage is ~20 KB, and exit() cuts off whatever a pipe has not drained yet.
3420
+ process.exitCode = cmd && !HELP_WORDS.has(cmd) ? 1 : 0;
3304
3421
  }
3305
3422
  } // end: every command but onboard
3306
3423
 
@@ -3328,6 +3445,7 @@ Usage:
3328
3445
  oats version [--json] kernel version; --json emits the
3329
3446
  Desktop CLI API v1 probe payload
3330
3447
  oats status [--json] agents, souls, running instances
3448
+ [--max-age <s>] reuse head observations up to <s> s old (below)
3331
3449
  oats server add <id> --ssh <alias> register another machine's OATS (OpenSSH alias,
3332
3450
  --workspace </abs/path> [--oats <p>] remote workspace, remote oats path; no keys stored;
3333
3451
  [--path <dir:dir>] --path = dirs prepended to the remote PATH, e.g. ~/.local/bin)
@@ -3337,8 +3455,8 @@ Usage:
3337
3455
  remote instance lives under ~/.oats/remote/)
3338
3456
  oats server roster [--server <id>] remote roster grouped by server and saved route
3339
3457
  [--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
3458
+ [--json] budget (45 s, 20 s per target); every instance the
3459
+ server reports is addressable by --home or name
3342
3460
  oats server forget <id> --instance <name> drop a saved route whose remote instance is gone
3343
3461
  (the roster shows it as missingRemotely)
3344
3462
  oats retire <instance> --home <path> retire exactly that home when two agents own an
@@ -3346,17 +3464,20 @@ Usage:
3346
3464
  oats okf harvest --server <id> run the knowledge harvest in a remote instance's
3347
3465
  --instance <name> [--json] saved home on its host
3348
3466
  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
3467
+ --instance <name> | --home <abs> remote instance, by its saved route or the server's
3468
+ roster (--print shows attach); oats 0.22.2 or later
3351
3469
  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
3470
+ --instance <name> | --home <abs> by its saved route or the server's roster; it must advertise
3353
3471
  [--model <m>] [--json] session-start (oats 0.22.9 or later)
3354
3472
  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
3473
+ ... [--dir <remote member>] [--home <abs>] instance home (an explicit --dir travels as is; a --home
3356
3474
  is its own context; else the registered workspace);
3357
3475
  the server must advertise operations (oats 0.22.16 or later)
3476
+ oats readiness|instance events|git|diff|stop the Desktop's reads and lifecycle plans, and retire --plan,
3477
+ ... --server <id> run on the instance's own machine; the host's envelope is
3478
+ relayed unchanged (the host must advertise the feature)
3358
3479
  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
3480
+ --instance <name> | --home <abs> attachments, by its home or name (bytes stream on
3360
3481
  --file <path> [--json] ssh stdin; sha256 verified); the server must
3361
3482
  advertise session-upload (oats 0.22.13 or later)
3362
3483
  oats onboard [<dir>] --workspace <repo ref> realize a workspace here: writes <dir>/oats-local.yaml
@@ -3401,14 +3522,14 @@ Usage:
3401
3522
  [--model <m>] [--json] (same identity, worktree, notes and launch env; no
3402
3523
  spawn hooks); --model replaces the recorded model
3403
3524
  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
3525
+ oats spawn <agent> [--task <text> | --task-file <f>] spawn an instance (tmux; --no-launch
3405
3526
  [--purpose <slug>] [--repo <r>] = scaffold only); the agent is a workspace
3406
3527
  [--parent <instance>] soul or a capability-defined agent
3407
3528
  [--relation child|sibling|parent|unrelated] --relation + --relative-to anchor the
3408
3529
  [--relative-to <instance>] new instance to an existing one; --parent X
3409
3530
  [--relative-root <agents-root>] disambiguates same-named team anchors
3410
3531
  [--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)
3532
+ [--work-dir <owner-work>] [--harness pi|claude|codex] [--backend tmux] [--yolo|--no-yolo] [--model <m>] [--branch <b>] child (default: unrelated, top-level)
3412
3533
  [--no-launch] [--json] without --launch-config/--harness the launch is the
3413
3534
  soul's preference: oats-local.yaml souls.launch.<soul>,
3414
3535
  then souls.launch."*", then the soul's launch:, then pi
@@ -3428,7 +3549,7 @@ Usage:
3428
3549
  a detached external retirement runs
3429
3550
  oats inspect [--dir <scope>] [--soul <name> one authoritative JSON answer for a GUI: souls
3430
3551
  [--agents-root <abs>]] [--home <abs>] (harness defaults, editability, instructions),
3431
- [--json] installed capabilities with health, effective
3552
+ [--max-age <s>] [--json] installed capabilities with health, effective
3432
3553
  layer bindings and activation, declared
3433
3554
  operations with availability; --home answers the
3434
3555
  running home's recorded modules and their drift
@@ -3469,19 +3590,20 @@ Usage:
3469
3590
  | remove <id> [--dir <d>] workspace repo is the current checkout; otherwise
3470
3591
  print the line to add (the file travels through Git)
3471
3592
  oats workspace status [--dir <d>] [--json] membership table (confirmed / no-backlink /
3472
- cannot-read / backlink-elsewhere), locked packages
3593
+ [--max-age <s>] cannot-read / backlink-elsewhere), locked packages
3473
3594
  oats capabilities [--dir <d>] [--json] every capability of every confirmed member (a
3474
- private one is listed as repo-owned: usable only by
3595
+ [--max-age <s>] private one is listed as repo-owned: usable only by
3475
3596
  its own repo's souls) + the locked packages
3476
3597
  oats souls [--dir <d>] [--json] every soul of every confirmed member + external souls
3477
- (souls have no private mode), with origin
3598
+ [--max-age <s>] (souls have no private mode), with origin
3478
3599
  (member <key> @ <commit> | package <id> v<ver>) and its
3479
3600
  teams on this deployment
3480
3601
  oats teams [--json] | add <label> --team <id> [--description <d>] | remove <label>
3481
3602
  | default <label> [--dir <d>] this deployment's teams (shared + local), the
3482
- default; add/remove/default edit oats-local.yaml
3603
+ [--max-age <s>] (the read form only) default; add/remove/default edit oats-local.yaml
3483
3604
  oats soul teams <soul>|'*' [--add a,b] [--remove a,b] [--default <l> | --clear-default]
3484
3605
  [--dir <d>] [--json] which teams a soul (or every soul) belongs to here
3606
+ [--max-age <s>] (without an edit)
3485
3607
  oats instance git <instance> [--home <abs>] [--dir <d>] [--json]
3486
3608
  read-only Git observation of the instance's work
3487
3609
  tree: branch, status (renames kept), ahead/behind
@@ -3536,6 +3658,18 @@ The turn record (core — every conversation captured, searchable, replicated):
3536
3658
  oats <namespace> <command> [args…] run an operational command only when its
3537
3659
  capability is active (e.g. oats okf harvest)
3538
3660
 
3661
+ Observation reuse (feature observe-max-age):
3662
+ --max-age <seconds> on the read verbs only — status, workspace status,
3663
+ souls, capabilities, inspect --soul|--home, and the
3664
+ read forms of teams and soul teams — reuse a remote
3665
+ head observation up to <seconds> old (0–86400; 0 is
3666
+ live) instead of asking the remote again; the JSON
3667
+ then carries observation { observedAt (the oldest
3668
+ head used), reused, localRevision (a digest of
3669
+ the local configuration read) }. Refused
3670
+ (E_BAD_ARGS) by every other command, an edit form,
3671
+ and with --server
3672
+
3539
3673
  Layers: ${LAYERS.join(", ")}. Workspace model v2: docs/design/2026-09-23-workspace-module-contracts.md.`;
3540
3674
  }
3541
3675
  } catch (e) {
@@ -3545,4 +3679,7 @@ Layers: ${LAYERS.join(", ")}. Workspace model v2: docs/design/2026-09-23-workspa
3545
3679
  // already names the offending file — the readers re-raise it with one.
3546
3680
  if (JSON_MODE) jsonFail(e.code, e.message);
3547
3681
  die(e.message);
3682
+ } finally {
3683
+ // The command's read session: every `git cat-file --batch` child ends before the process does.
3684
+ if (readSession) await readSession.close();
3548
3685
  }