@awebai/oats 0.31.0 → 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,7 +19,7 @@
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";
@@ -28,16 +28,17 @@ import {
28
28
  LAYERS, OATS_VERSION, manifestOperations, upgradeHomeMeta,
29
29
  capabilityManifests, capabilityTrust, capabilityExecutablePath,
30
30
  officialPackageCatalog, officialCatalogFile, officialCapabilityAliases, resolvedFromHome, resolvedFromPrepared, teamEnv, isWorkspaceHome, preWorkspaceHome, isCapturedHome, capturedHomeRefusal, composeInstanceAgentsMd, parseYamlNested, withConfigFile,
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
+ 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,
32
32
  } from "../lib/core.mjs";
33
33
  import {
34
- writeFileAtomic, LOCK_FILE, readLock, writeLock, resolvePackages, memoizedRemote,
34
+ writeFileAtomic, LOCK_FILE, readLock, readLockIfPresent, writeLock, resolvePackages, memoizedRemote,
35
35
  classifyPackageValue, parsePackageRequest } from "../lib/packages.mjs";
36
- import { loadLocal, validateWorkspace, validateLocal, discoverPackageSouls, workspaceWarnings } from "../lib/workspace.mjs";
36
+ import { loadLocal, validateWorkspace, validateLocal, discoverPackageSouls, workspaceWarnings, memberRowByKey } from "../lib/workspace.mjs";
37
37
  import { recordedTeams, reportRows, soulKeyOf, soulTeams, teamModel } from "../lib/teams.mjs";
38
38
  import { launchLayers } from "../lib/launch-preference.mjs";
39
39
  import { parseConfigData } from "../lib/config-data.mjs";
40
40
  import * as remoteModule from "../lib/remote.mjs";
41
+ import { activateLocalInputs, localRevision } from "../lib/local-inputs.mjs";
41
42
  import YAML from "yaml";
42
43
  import { attachArgv, checkRemote, forgetSnapshot, getServer, inspectRemote, startRemote, restartRemote, launchConfigRemote, scheduleRemote, listSnapshots, readServers, rosterGroups, routeCommand, targetOf, validateServer, writeServers, SERVERS_FILE } from "../lib/servers.mjs";
43
44
  import { spawnSync as spawnSyncProc } from "node:child_process";
@@ -367,7 +368,7 @@ async function inspectCmd() {
367
368
  if (t) {
368
369
  if (t.resolutionError) return bail(t.resolutionError.code, t.resolutionError.message, t.resolutionError.details ?? undefined);
369
370
  const doc = inspectDocument(t, { kernel: OATS_VERSION });
370
- if (JSON_MODE) { jsonOk(doc); return; }
371
+ if (JSON_MODE) { jsonOk(withObservation(doc)); return; }
371
372
  printWorkspaceInspect(doc); return;
372
373
  }
373
374
  }
@@ -536,6 +537,7 @@ async function workspaceOperation(t, { bail, address, layer, opName }) {
536
537
  const env = { ...lp.env(mod.name, settings), OATS_OPERATION: address, OATS_CONTEXT: t.deployment, OATS_ROOT: t.agentsRoot, PI_AGENTS_ROOT: t.agentsRoot };
537
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 });
538
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
539
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" });
540
542
  finishOperation({ r, bail, address, provider, op, argFlags, cwd, home: t.home, meta: t.meta, api: INSPECT_OPERATIONS_API });
541
543
  }
@@ -686,6 +688,7 @@ function serializeLaunchConfigs(map) {
686
688
  }
687
689
  if (e.model !== undefined) lines.push(` model: ${yamlQuoted(e.model)}`);
688
690
  if (e.yolo !== undefined) lines.push(` yolo: ${e.yolo}`);
691
+ if (e.default === true) lines.push(" default: true");
689
692
  }
690
693
  return lines.join("\n") + "\n";
691
694
  }
@@ -700,6 +703,7 @@ function normalizeLaunchConfig(e) {
700
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 }])) } : {}),
701
704
  ...(e.model !== undefined ? { model: e.model } : {}),
702
705
  ...(e.yolo !== undefined ? { yolo: e.yolo } : {}),
706
+ ...(e.default === true ? { default: true } : {}),
703
707
  };
704
708
  }
705
709
  function readLaunchConfigsModel(local) {
@@ -716,7 +720,7 @@ function readLaunchConfigsModel(local) {
716
720
  * `set --keep-env`. */
717
721
  function publicLaunchConfig(e, extra = {}) {
718
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 }]));
719
- 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 };
720
724
  }
721
725
  /** The scope a launch-config command reads: --dir (or cwd), a running
722
726
  * home's recorded context (--home), or a soul's own member context
@@ -766,7 +770,7 @@ function launchPreview(bail) {
766
770
  // (E_LAUNCH_LEGACY: re-spawn it from the deployment).
767
771
  let d;
768
772
  try { d = describeLaunchCommand(meta.command); } catch (e) { bail(e.code || "E_LAUNCH_COMMAND_UNSUPPORTED", e.message); }
769
- 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 });
770
774
  return;
771
775
  }
772
776
  const agentsRoot = agentsRootOfHome(home);
@@ -788,7 +792,7 @@ function launchPreview(bail) {
788
792
  const command = renderLaunchRecipe(recipe, { home, instance, redact: true });
789
793
  const d = describeLaunchCommand(command);
790
794
  const environment = d.environment.map((e) => e.reference && recipe.env[e.name]?.fromEnv ? { name: e.name, fromEnv: recipe.env[e.name].fromEnv } : e);
791
- 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 });
792
796
  }
793
797
  async function launchConfigCmd() {
794
798
  const bail = (code, msg, details) => (JSON_MODE ? jsonFail(code, msg, details) : die(msg));
@@ -817,7 +821,7 @@ async function launchConfigCmd() {
817
821
  if (!configurations.length) { console.log(`No launch configurations are declared${file ? ` in ${shortPath(file)}` : ` (no oats-local.yaml in reach of ${dir})`}`); return; }
818
822
  for (const c of configurations) {
819
823
  const env = Object.entries(c.env).map(([n, v]) => v.fromEnv ? `${n}=$${v.fromEnv}` : `${n}=<redacted>`).join(" ");
820
- 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}` : ""}`);
821
825
  }
822
826
  return;
823
827
  }
@@ -860,6 +864,8 @@ async function launchConfigCmd() {
860
864
  try { validateLaunchConfig(name, entry, `--file ${f}`); } catch (e) { bail(e.code || "E_LAUNCH_CONFIG_INVALID", e.message); }
861
865
  if (!Object.hasOwn(entry, "harness")) noteRuntimeName(`runtime in the --file definition (written as harness)`);
862
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); }
863
869
  }
864
870
  let next;
865
871
  try { next = replaceLaunchConfigsBlock(text, serializeLaunchConfigs(model)); } catch (e) { bail(e.code || "E_CONFIG_BROKEN", `${e.message}; nothing was written`); }
@@ -1013,11 +1019,60 @@ async function readinessCmd() {
1013
1019
  // remotes (lib/workspace.mjs), packages resolve to exact commits (lib/packages.mjs,
1014
1020
  // lock v3) and the only persisted state is `oats-lock.json` beside oats-local.yaml.
1015
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
+
1016
1071
  /** Remote options threaded into every remote call. OATS_REMOTE_CACHE relocates
1017
- * 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. */
1018
1073
  function remoteOptionsFromEnv() {
1019
1074
  const cacheDir = process.env.OATS_REMOTE_CACHE;
1020
- return cacheDir ? { cacheDir: resolve(cacheDir) } : {};
1075
+ return { ...(cacheDir ? { cacheDir: resolve(cacheDir) } : {}), session: commandSession() };
1021
1076
  }
1022
1077
 
1023
1078
  /** The v2 deployment context at --dir: { dir, localPath, local, deploymentDir, remoteOptions }. */
@@ -1443,7 +1498,7 @@ async function workspaceCmd() {
1443
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` });
1444
1499
  }
1445
1500
  await workspaceStatusFacts(result, discovery, lock, ctx);
1446
- if (JSON_MODE) { jsonOk(result); return; }
1501
+ if (JSON_MODE) { jsonOk(withObservation(result)); return; }
1447
1502
  console.log(`workspace ${workspaceName(discovery)} (${discovery.key} @ ${short(discovery.commit)}) local ${shortPath(ctx.localPath)}\n`);
1448
1503
  if (standalone) console.log(` (${standaloneNote(discovery)})\n`);
1449
1504
  console.log("Members:");
@@ -1533,7 +1588,7 @@ async function teamsCmd() {
1533
1588
  else if (sub === "default") result = V.teamsDefault(ctx, label);
1534
1589
  } catch (e) { if (typeof e?.code === "string" && e.code.startsWith("E_")) return bail(e.code, e.message, e.details); throw e; }
1535
1590
  const doc = V.teamsDocument(result ? { ...ctx, local: result.local } : ctx);
1536
- if (JSON_MODE) { jsonOk(result ? { ...doc, changed: result.changed } : doc); return; }
1591
+ if (JSON_MODE) { jsonOk(result ? { ...doc, changed: result.changed } : withObservation(doc)); return; }
1537
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");
1538
1593
  console.log(`default ${doc.defaultTeam ?? "(none)"}`);
1539
1594
  if (!doc.teams.length) console.log("teams (none: `oats aweb setup` creates them, or `oats teams add <label> --team <id>`)");
@@ -1560,7 +1615,7 @@ async function soulCmd() {
1560
1615
  else {
1561
1616
  // The soul is named as for spawn: E_SOUL_UNKNOWN / E_SOUL_AMBIGUOUS; package souls come from the lock.
1562
1617
  let lock = null;
1563
- 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); }
1564
1619
  let discovery, entry;
1565
1620
  try {
1566
1621
  const { discoverOrStandalone, findSoulEntry } = await import("../lib/instance-resolution.mjs");
@@ -1577,7 +1632,7 @@ async function soulCmd() {
1577
1632
  if (mutating) result = V.soulTeamsEdit(teamsCtx, key, edit);
1578
1633
  doc = V.soulTeamsDocument(result ? { ...teamsCtx, local: result.local } : teamsCtx, { soul, key });
1579
1634
  } catch (e) { if (typeof e?.code === "string" && e.code.startsWith("E_")) return bail(e.code, e.message, e.details); throw e; }
1580
- if (JSON_MODE) { jsonOk(result ? { ...doc, changed: result.changed } : doc); return; }
1635
+ if (JSON_MODE) { jsonOk(result ? { ...doc, changed: result.changed } : withObservation(doc)); return; }
1581
1636
  if (result) console.log(result.changed ? `Updated the teams of ${key === "*" ? "every soul" : key} in ${shortPath(teamsCtx.localPath)}` : "Nothing to change");
1582
1637
  console.log(`${key === "*" ? "every soul" : key} on this computer: default ${doc.defaultTeam ? `${doc.defaultTeam.label} (${doc.defaultTeam.from})` : "(none)"}`);
1583
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(",")]));
@@ -1596,7 +1651,7 @@ async function itemsCmd(kind) {
1596
1651
  if (kind === "capabilities") await capabilityFacts(items, discovery, lock, ctx, remote);
1597
1652
  if (kind === "souls") await soulFacts(items, discovery, lock, ctx, remote);
1598
1653
  const standalone = discovery.standalone === true;
1599
- 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; }
1600
1655
  console.log(`${kind} of workspace ${workspaceName(discovery)} (${discovery.key} @ ${short(discovery.commit)})${standalone ? ` — ${standaloneNote(discovery)}` : ""}\n`);
1601
1656
  if (!items.length) console.log(" (none)");
1602
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 ?? "—"]));
@@ -1616,14 +1671,24 @@ async function capabilityFacts(rows, discovery, lock, ctx, remote = remoteModule
1616
1671
  for (const [id, entry] of Object.entries(lock?.packages || {})) {
1617
1672
  try { packages.set(id, await lockedPackageCapabilities(id, entry, { catalog, remote, remoteOptions })); } catch { packages.set(id, null); }
1618
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
+ }
1619
1684
  // A member capability's fingerprint is its Git tree at the commit (one listing per member commit); a
1620
1685
  // package's is the package integrity (the lock).
1621
1686
  const trees = new Map();
1622
1687
  const treeOf = async ({ repoKey, commit }, ref, dir) => {
1623
1688
  const key = `${repoKey}\0${commit}`;
1624
1689
  if (!trees.has(key)) {
1625
- const member = discovery.members.find((m) => m.key === repoKey);
1626
- 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);
1627
1692
  trees.set(key, remoteModule.remoteTreeOids(ref, commit, [...new Set(dirs)], remoteOptions).catch(() => new Map()));
1628
1693
  }
1629
1694
  return (await trees.get(key)).get(dir) ?? null;
@@ -1634,7 +1699,7 @@ async function capabilityFacts(rows, discovery, lock, ctx, remote = remoteModule
1634
1699
  const read = packages.get(row.package), cap = read?.capabilities.find((c) => c.name === row.name);
1635
1700
  if (cap) ({ manifest, dir } = cap), ref = read.ref;
1636
1701
  } else {
1637
- 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);
1638
1703
  if (cap) { manifest = cap.manifest; dir = cap.path; ref = memberRef(discovery, remoteModule, row.repoKey); }
1639
1704
  }
1640
1705
  const provides = manifest ? await capabilityProvides({ ref, commit: row.commit, dir, manifest, remote, remoteOptions }) : { skills: null, commands: null, hooks: null };
@@ -1649,9 +1714,13 @@ async function capabilityFacts(rows, discovery, lock, ctx, remote = remoteModule
1649
1714
  * discovery and the lock without spawning (soulSpawnability). */
1650
1715
  async function soulFacts(rows, discovery, lock, ctx, remote = remoteModule) {
1651
1716
  const { soulSpawnability } = await import("../lib/instance-resolution.mjs");
1652
- const entryOf = (row) => row.kind === "package" ? (discovery.packageSouls || []).find((p) => p.qualifiedName === row.qualifiedName)
1653
- : row.kind === "external" ? (discovery.external || []).map((e) => e.soul).find((x) => x.name === row.name && x.repoKey === row.repoKey)
1654
- : 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);
1655
1724
  for (const row of rows) {
1656
1725
  const entry = entryOf(row);
1657
1726
  row.file = { path: `${row.path}/soul.yaml`, url: remoteModule.browseUrl(row.repoKey, row.commit, `${row.path}/soul.yaml`) };
@@ -1689,7 +1758,7 @@ async function statusDrift(data) {
1689
1758
  if (!anything) return { drift: new Map(), soul: new Map(), souls, unreachable: null };
1690
1759
  const deploymentDir = dirname(ctx.path);
1691
1760
  let lock = null;
1692
- try { if (existsSync(join(deploymentDir, LOCK_FILE))) lock = readLock(deploymentDir); } catch { lock = null; }
1761
+ try { lock = readLockIfPresent(deploymentDir); } catch { lock = null; }
1693
1762
  let discovery;
1694
1763
  // The standalone view (decisions 10/25) is a discovery too: drift of a standalone
1695
1764
  // instance is computed against its member's current state, not reported "unreachable".
@@ -1707,9 +1776,12 @@ async function statusDrift(data) {
1707
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 */ } }
1708
1777
  }
1709
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); }
1710
1782
  for (const [, stamp] of souls) {
1711
- 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; }
1712
- 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);
1713
1785
  if (member && typeof member.commit === "string" && (member.confirmed || (discovery.standalone === true && member.key === discovery.key))) stamp.current = member.commit;
1714
1786
  }
1715
1787
  return { drift, soul, souls, unreachable: null };
@@ -1780,7 +1852,8 @@ async function status() {
1780
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 } : {}) };
1781
1853
  }
1782
1854
  }
1783
- 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;
1784
1857
  }
1785
1858
  console.log(`oats status — agents root ${shortPath(root)}\n`);
1786
1859
  if (ws?.unreachable) console.log(` workspace: unreachable (${ws.unreachable.reason}) — drift unknown\n`);
@@ -2069,7 +2142,7 @@ async function spawnCmd() {
2069
2142
  // (the deployment's cache had no entry for its commit): the result says so.
2070
2143
  if (prepared) r.soulFetched = soulFetched;
2071
2144
  if (JSON_MODE) { jsonOk(r); return; }
2072
- 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)" : ""}`);
2073
2146
  return;
2074
2147
  }
2075
2148
  } catch (e) {
@@ -2081,7 +2154,7 @@ async function spawnCmd() {
2081
2154
  // A launch refusal (configuration, executable, environment reference,
2082
2155
  // model, harness) is a fact about the selection, not a spawn-mechanism
2083
2156
  // failure: it keeps its own code so a GUI can act on it.
2084
- 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; }
2085
2158
  // An unmet declared requirement is a fact about the soul's configuration
2086
2159
  // (with a remedy), not a spawn-mechanism failure: keep its code and details.
2087
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; }
@@ -2782,6 +2855,7 @@ async function capabilityCommand() {
2782
2855
  // OATS_SOUL is the recorded soul or nothing: an ambient value inherited from the
2783
2856
  // invoking process names some other soul (a coordinator's own), never this one.
2784
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
2785
2859
  const r = spawnSync("node", [abs, ...rest, ...rawArgs.slice(2)], { stdio: "inherit", env: {
2786
2860
  ...inherited, OATS_CAPABILITY: m.capability,
2787
2861
  // Package-runtime boundary: dispatched commands receive the active
@@ -2803,7 +2877,9 @@ async function capabilityCommand() {
2803
2877
  } });
2804
2878
  // Child never ran (spawn error): nothing reached stdout — keep the envelope contract.
2805
2879
  if (r.error) bail("E_CAPABILITY_BROKEN", `oats ${cmd} ${sub}: ${r.error.message || r.error}`);
2806
- 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));
2807
2883
  }
2808
2884
  }
2809
2885
 
@@ -2861,7 +2937,7 @@ function versionCmd() {
2861
2937
  // Phase B: `instance-modules` and `spawn-provider-payload` are advertised only once spawn
2862
2938
  // runs on resolve/materialize (contract §6); a feature the binary does not implement is
2863
2939
  // never listed.
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 }));
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 }));
2865
2941
  return;
2866
2942
  }
2867
2943
  console.log(`@awebai/oats ${OATS_VERSION} (desktop API v1)`);
@@ -3253,6 +3329,17 @@ try {
3253
3329
  const selector = head.find((a) => /^--(deployment|resolution|artifact-set)$/.test(a));
3254
3330
  const refuse = (message, details) => { if (JSON_MODE) jsonFail("E_UNSUPPORTED_MODE", message, details); die(message); };
3255
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
+ }
3256
3343
  const inherited = ["OATS_RESOLUTION", "OATS_DEPLOYMENT"].filter((k) => process.env[k]);
3257
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 });
3258
3345
  if (cmd === "inspect" && head.includes("--request")) {
@@ -3329,7 +3416,8 @@ else if (cmd && !cmd.startsWith("--") && !HELP_WORDS.has(cmd) && JSON_MODE) json
3329
3416
  else {
3330
3417
  if (cmd && !HELP_WORDS.has(cmd) && !cmd.startsWith("--")) console.error(`oats: unknown command "${cmd}" — no kernel subcommand or active capability namespace matches\n`);
3331
3418
  console.log(usageText());
3332
- 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;
3333
3421
  }
3334
3422
  } // end: every command but onboard
3335
3423
 
@@ -3357,6 +3445,7 @@ Usage:
3357
3445
  oats version [--json] kernel version; --json emits the
3358
3446
  Desktop CLI API v1 probe payload
3359
3447
  oats status [--json] agents, souls, running instances
3448
+ [--max-age <s>] reuse head observations up to <s> s old (below)
3360
3449
  oats server add <id> --ssh <alias> register another machine's OATS (OpenSSH alias,
3361
3450
  --workspace </abs/path> [--oats <p>] remote workspace, remote oats path; no keys stored;
3362
3451
  [--path <dir:dir>] --path = dirs prepended to the remote PATH, e.g. ~/.local/bin)
@@ -3460,7 +3549,7 @@ Usage:
3460
3549
  a detached external retirement runs
3461
3550
  oats inspect [--dir <scope>] [--soul <name> one authoritative JSON answer for a GUI: souls
3462
3551
  [--agents-root <abs>]] [--home <abs>] (harness defaults, editability, instructions),
3463
- [--json] installed capabilities with health, effective
3552
+ [--max-age <s>] [--json] installed capabilities with health, effective
3464
3553
  layer bindings and activation, declared
3465
3554
  operations with availability; --home answers the
3466
3555
  running home's recorded modules and their drift
@@ -3501,19 +3590,20 @@ Usage:
3501
3590
  | remove <id> [--dir <d>] workspace repo is the current checkout; otherwise
3502
3591
  print the line to add (the file travels through Git)
3503
3592
  oats workspace status [--dir <d>] [--json] membership table (confirmed / no-backlink /
3504
- cannot-read / backlink-elsewhere), locked packages
3593
+ [--max-age <s>] cannot-read / backlink-elsewhere), locked packages
3505
3594
  oats capabilities [--dir <d>] [--json] every capability of every confirmed member (a
3506
- private one is listed as repo-owned: usable only by
3595
+ [--max-age <s>] private one is listed as repo-owned: usable only by
3507
3596
  its own repo's souls) + the locked packages
3508
3597
  oats souls [--dir <d>] [--json] every soul of every confirmed member + external souls
3509
- (souls have no private mode), with origin
3598
+ [--max-age <s>] (souls have no private mode), with origin
3510
3599
  (member <key> @ <commit> | package <id> v<ver>) and its
3511
3600
  teams on this deployment
3512
3601
  oats teams [--json] | add <label> --team <id> [--description <d>] | remove <label>
3513
3602
  | default <label> [--dir <d>] this deployment's teams (shared + local), the
3514
- default; add/remove/default edit oats-local.yaml
3603
+ [--max-age <s>] (the read form only) default; add/remove/default edit oats-local.yaml
3515
3604
  oats soul teams <soul>|'*' [--add a,b] [--remove a,b] [--default <l> | --clear-default]
3516
3605
  [--dir <d>] [--json] which teams a soul (or every soul) belongs to here
3606
+ [--max-age <s>] (without an edit)
3517
3607
  oats instance git <instance> [--home <abs>] [--dir <d>] [--json]
3518
3608
  read-only Git observation of the instance's work
3519
3609
  tree: branch, status (renames kept), ahead/behind
@@ -3568,6 +3658,18 @@ The turn record (core — every conversation captured, searchable, replicated):
3568
3658
  oats <namespace> <command> [args…] run an operational command only when its
3569
3659
  capability is active (e.g. oats okf harvest)
3570
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
+
3571
3673
  Layers: ${LAYERS.join(", ")}. Workspace model v2: docs/design/2026-09-23-workspace-module-contracts.md.`;
3572
3674
  }
3573
3675
  } catch (e) {
@@ -3577,4 +3679,7 @@ Layers: ${LAYERS.join(", ")}. Workspace model v2: docs/design/2026-09-23-workspa
3577
3679
  // already names the offending file — the readers re-raise it with one.
3578
3680
  if (JSON_MODE) jsonFail(e.code, e.message);
3579
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();
3580
3685
  }
@@ -50,6 +50,11 @@ launch-configs: # named ways this host starts a har
50
50
  CLAUDE_CONFIG_DIR: { fromEnv: PERSONAL_CLAUDE_DIR }
51
51
  model: opus
52
52
  yolo: false
53
+ mine:
54
+ harness: claude
55
+ default: true # this host's baseline for every claude launch (0.32)
56
+ env:
57
+ CLAUDE_CONFIG_DIR: /home/ana/.claude-personal
53
58
  ```
54
59
 
55
60
  Schema: [`oats-local.schema.json`](oats-local.schema.json). Unknown keys are
@@ -70,7 +75,7 @@ refused (`E_WORKSPACE_SCHEMA`).
70
75
  | `host.name` | This machine's name. A workspace trigger or schedule runs only on the host named by its `runsOn` ([schedules.md](schedules.md)). |
71
76
  | `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). |
72
77
  | `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`. |
73
- | `launch-configs.<name>` | A named way to start a harness on this host, chosen at spawn or session start, never by the soul. See [Launch configurations](#launch-configurations). |
78
+ | `launch-configs.<name>` | A named way to start a harness on this host, chosen at spawn or session start, never by the soul. `default: true` makes it this host's baseline for its harness (0.32). See [Launch configurations](#launch-configurations). |
74
79
  | `souls.launch` | This machine's launch preference per soul (0.30): `"*"` for every soul, a soul's own entry (its name, or `<package>/<soul>`) over it. A value is a `launch-configs` name or an inline `{ harness, model? }`. It overrides the soul's own `launch:`; explicit spawn flags win over both. See [Launch preferences](#launch-preferences). |
75
80
 
76
81
  How teams are resolved, and what a messaging provider does with them, is in
@@ -81,8 +86,9 @@ How teams are resolved, and what a messaging provider does with them, is in
81
86
  An entry has `harness` (`pi` \| `claude` \| `codex`, required), `executable`
82
87
  (a bare name looked up on `PATH`, or a path relative to this deployment
83
88
  directory), `args` (literal, no shell), `env` (a literal string, or
84
- `{ fromEnv: NAME }` resolved on the host at start), `model` and `yolo`. A
85
- launch configuration is a host choice: a soul never names one.
89
+ `{ fromEnv: NAME }` resolved on the host at start), `model`, `yolo` and
90
+ `default` (0.32; see [the harness default](#the-harness-default)). A launch
91
+ configuration is a host choice: a soul never names one.
86
92
 
87
93
  - Select one with `--launch-config <name>` on `oats spawn`,
88
94
  `oats session start` and `oats session restart`. A named configuration is
@@ -104,6 +110,50 @@ launch configuration is a host choice: a soul never names one.
104
110
  - The old key `runtime` is still read as `harness`, with a
105
111
  `deprecated-runtime-name` warning.
106
112
 
113
+ ### The harness default
114
+
115
+ `default: true` makes a configuration this host's baseline for its harness
116
+ (0.32, feature `launch-config-default`). Use it for what every launch of a
117
+ harness on this machine needs, whatever soul or preference chose it: an
118
+ account directory (`CLAUDE_CONFIG_DIR`), a wrapper `executable`, an argument.
119
+
120
+ - **When it applies:** a new launch that picks the harness without naming a
121
+ configuration: a soul's `launch:`, an inline `souls.launch` preference,
122
+ `--harness` (on a spawn, or on `session start|restart` of an existing
123
+ home), `--reselect-launch`, and the host default (`pi`). It supplies the
124
+ executable, args, env and `yolo`. A `yolo` recorded from a default stays
125
+ with it: a later `--launch-config none` or another harness does not carry
126
+ it over.
127
+ - **The model** comes from whatever picked the harness (`--model`, then the
128
+ preference); the default's `model` is the last fallback, before the
129
+ harness's own.
130
+ - **A named configuration runs as declared**: `--launch-config <name>` or a
131
+ `souls.launch` name never inherits from the default. `--launch-config none`
132
+ (or a `souls.launch` entry of `none`) asks for the bare harness and
133
+ bypasses it.
134
+ - **One per harness.** A second `default: true` for the same harness is
135
+ refused (`E_LAUNCH_CONFIG_INVALID`, naming both); move it by clearing the
136
+ old one first.
137
+ - **Existing homes keep their launch** until `--reselect-launch` or a
138
+ respawn, like any change of preference. Declaring a default is such a
139
+ change: `oats readiness --home` warns `launch-changed` on each existing
140
+ home the default would now apply to, until it is restarted with
141
+ `--reselect-launch` or respawned.
142
+ - **It is visible.** `oats launch-config list` marks it; `spawn --preview`,
143
+ `launch-config preview`, `instance.json` and `oats inspect --home` say when
144
+ a launch's configuration came from the default (`launchConfigDefault`).
145
+ A default with `yolo: true` turns yolo on for every launch of that harness
146
+ here: the preview shows it.
147
+ - **Every kernel that reads the deployment needs OATS 0.32+.** OATS 0.31
148
+ and older refuse the whole `oats-local.yaml` (`E_WORKSPACE_SCHEMA`) once a
149
+ configuration declares `default`.
150
+
151
+ `oats-claude-config` (a one-line file naming the claude binary, found walking
152
+ up from the deployment) is no longer read. A new claude launch with one in
153
+ reach is refused (`E_CLAUDE_CONFIG_REMOVED`) naming the file: declare the
154
+ name it holds as the claude default (`executable: <name>`, `default: true`)
155
+ and delete the file. Homes launched with it keep their recorded executable.
156
+
107
157
  ### Launch preferences
108
158
 
109
159
  A soul says what its role should run on, and each machine may override it
@@ -128,9 +178,11 @@ souls:
128
178
  harness and replaces only its model.
129
179
  - A **launch configuration** name runs that configuration's full recipe. An
130
180
  **inline or soul preference** runs its harness the way this host starts it
131
- without a configuration (the executable on `PATH`, no args, no env), with
132
- its `model`. A preference without `model` uses the harness's own model; it
133
- never borrows a lower layer's.
181
+ without a configuration: [the harness default](#the-harness-default) if
182
+ one is declared, else the executable on `PATH` with no args and no env,
183
+ with the preference's `model`. A preference without `model` uses the
184
+ harness default's model, else the harness's own; it never borrows a lower
185
+ layer's.
134
186
  - **A missing harness is refused**, never replaced: `E_HARNESS_UNAVAILABLE`
135
187
  names the layer that chose it and the fix (install the harness, or override
136
188
  it here in `souls.launch`). `oats souls` still lists the soul, with the
@@ -274,5 +326,10 @@ oats spawn <soul> --preview # the exact modules, teams and provider payloads
274
326
  oats doctor # this deployment's files and the lock
275
327
  ```
276
328
 
277
- Environment: `OATS_REMOTE_CACHE` relocates the fetch cache;
278
- `OATS_PACKAGE_CATALOG` names an alternative package catalog file.
329
+ Environment: `OATS_REMOTE_CACHE` relocates the fetch cache (which also holds
330
+ the bounded parsed-read cache and the observations `--max-age` reuses; all of
331
+ it is safe to delete); `OATS_PACKAGE_CATALOG` names an alternative package
332
+ catalog file. The read verbs (`status`, `workspace status`, `souls`,
333
+ `capabilities`, `inspect`, and the read forms of `teams` and `soul teams`)
334
+ take `--max-age <seconds>` to reuse a remote head observed that recently
335
+ ([Observation reuse](desktop-cli-api.md#observation-reuse-feature-observe-max-age-oats-0311)).