@awebai/oats 0.31.0 → 0.33.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/README.md CHANGED
@@ -208,7 +208,7 @@ Begin with a small team and a real piece of work:
208
208
  4. Create instances for their assignments.
209
209
  5. Verify that work, communication, learning and handoff behave as intended.
210
210
 
211
- On a machine with Node.js 22+, Git and tmux, and a workspace repository to point at:
211
+ On a machine with Node.js 22+, Git (2.45+ to fetch only what OATS reads; an older git fetches whole trees) and tmux, and a workspace repository to point at:
212
212
 
213
213
  ```bash
214
214
  npm install -g @awebai/oats
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, enclosingInstanceHome, logicalCwd, 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);
@@ -785,10 +789,10 @@ function launchPreview(bail) {
785
789
  let plan;
786
790
  try { plan = planLaunch({ home, instance, meta, contextDir: context, agentLike, selection: sel, resolvedCfg: r, preview: true }); } catch (e) { bail(e.code || "E_BAD_ARGS", e.message); }
787
791
  const { recipe } = plan;
788
- const command = renderLaunchRecipe(recipe, { home, instance, redact: true });
792
+ const command = renderLaunchRecipe(recipe, { home, instance, redact: true, trustHome: plan.trustHome });
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,67 @@ 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", () => { sayReadNotices(); 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
+ /** What the read session found worth telling the operator (a remote that cannot serve partial fetches),
1043
+ * once, on stderr when the command ends: stdout, and so every JSON answer, is unchanged. */
1044
+ function sayReadNotices() {
1045
+ for (const notice of readSession?.notices.splice(0) ?? []) process.stderr.write(`oats: warning: ${notice}\n`);
1046
+ }
1047
+ /** Which kernel command forms take --max-age: THE allow-list (docs/desktop-cli-api.md "Observation reuse").
1048
+ * → null when this form reads with observation reuse, else the E_BAD_ARGS message. `head` is argv before `--`. */
1049
+ const MAX_AGE_READS = "status, workspace status, souls, capabilities, inspect --soul|--home, spawn --preview, and the read forms of teams and soul teams";
1050
+ function maxAgeRefusal(command, head) {
1051
+ const word = (i) => (head[i] !== undefined && !head[i].startsWith("--") ? head[i] : undefined);
1052
+ const refuse = (form) => `--max-age is not accepted by \`oats ${form}\`: only the read verbs reuse observations (${MAX_AGE_READS})`;
1053
+ if (head.includes("--server")) return "--max-age cannot be combined with --server: observation reuse is local to this machine";
1054
+ switch (command) {
1055
+ case "status": case "souls": case "capabilities": case "inspect": return null;
1056
+ // A preview reads (feature spawn-preview-max-age); an apply always observes live.
1057
+ case "spawn": return head.includes("--preview") ? null : refuse("spawn");
1058
+ case "workspace": return word(1) === "status" ? null : refuse(["workspace", word(1)].filter(Boolean).join(" "));
1059
+ case "teams": return word(1) === undefined ? null : refuse(`teams ${word(1)}`);
1060
+ case "soul": {
1061
+ if (word(1) !== "teams") return refuse(["soul", word(1)].filter(Boolean).join(" "));
1062
+ const edit = ["--add", "--remove", "--default", "--clear-default"].find((f) => head.includes(f));
1063
+ return edit ? refuse(`soul teams ${edit}`) : null;
1064
+ }
1065
+ default: {
1066
+ const sub = ["package", "schedule", "session", "trigger", "automations", "launch-config", "server", "instance", "operation", "pane"].includes(command) ? word(1) : undefined;
1067
+ return refuse([command, sub].filter(Boolean).join(" "));
1068
+ }
1069
+ }
1070
+ }
1071
+ /** The observation block: the heads' oldest observedAt and reuse (the read session) and the revision of
1072
+ * the local configuration this command read (lib/local-inputs.mjs, recording since dispatch). */
1073
+ const observationBlock = () => ({ ...commandSession().observation(), localRevision: localRevision() });
1074
+ /** A read verb's JSON with the observation block — only when --max-age was given (0 included); without
1075
+ * it the document is exactly what it was before the feature (Desktop decodes closed shapes). */
1076
+ const withObservation = (doc) => (maxAgeGiven === null ? doc : { ...doc, observation: observationBlock() });
1077
+
1016
1078
  /** Remote options threaded into every remote call. OATS_REMOTE_CACHE relocates
1017
- * the content-addressed fetch cache (tests never touch ~/.cache). */
1079
+ * the content-addressed fetch cache (tests never touch ~/.cache); `session` is the command's read session. */
1018
1080
  function remoteOptionsFromEnv() {
1019
1081
  const cacheDir = process.env.OATS_REMOTE_CACHE;
1020
- return cacheDir ? { cacheDir: resolve(cacheDir) } : {};
1082
+ return { ...(cacheDir ? { cacheDir: resolve(cacheDir) } : {}), session: commandSession() };
1021
1083
  }
1022
1084
 
1023
1085
  /** The v2 deployment context at --dir: { dir, localPath, local, deploymentDir, remoteOptions }. */
@@ -1443,7 +1505,7 @@ async function workspaceCmd() {
1443
1505
  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
1506
  }
1445
1507
  await workspaceStatusFacts(result, discovery, lock, ctx);
1446
- if (JSON_MODE) { jsonOk(result); return; }
1508
+ if (JSON_MODE) { jsonOk(withObservation(result)); return; }
1447
1509
  console.log(`workspace ${workspaceName(discovery)} (${discovery.key} @ ${short(discovery.commit)}) local ${shortPath(ctx.localPath)}\n`);
1448
1510
  if (standalone) console.log(` (${standaloneNote(discovery)})\n`);
1449
1511
  console.log("Members:");
@@ -1533,7 +1595,7 @@ async function teamsCmd() {
1533
1595
  else if (sub === "default") result = V.teamsDefault(ctx, label);
1534
1596
  } catch (e) { if (typeof e?.code === "string" && e.code.startsWith("E_")) return bail(e.code, e.message, e.details); throw e; }
1535
1597
  const doc = V.teamsDocument(result ? { ...ctx, local: result.local } : ctx);
1536
- if (JSON_MODE) { jsonOk(result ? { ...doc, changed: result.changed } : doc); return; }
1598
+ if (JSON_MODE) { jsonOk(result ? { ...doc, changed: result.changed } : withObservation(doc)); return; }
1537
1599
  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
1600
  console.log(`default ${doc.defaultTeam ?? "(none)"}`);
1539
1601
  if (!doc.teams.length) console.log("teams (none: `oats aweb setup` creates them, or `oats teams add <label> --team <id>`)");
@@ -1560,7 +1622,7 @@ async function soulCmd() {
1560
1622
  else {
1561
1623
  // The soul is named as for spawn: E_SOUL_UNKNOWN / E_SOUL_AMBIGUOUS; package souls come from the lock.
1562
1624
  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); }
1625
+ try { lock = readLockIfPresent(ctx.deploymentDir); } catch (e) { return bail(e.code || "E_LOCK_SCHEMA", e.message, e.details); }
1564
1626
  let discovery, entry;
1565
1627
  try {
1566
1628
  const { discoverOrStandalone, findSoulEntry } = await import("../lib/instance-resolution.mjs");
@@ -1577,7 +1639,7 @@ async function soulCmd() {
1577
1639
  if (mutating) result = V.soulTeamsEdit(teamsCtx, key, edit);
1578
1640
  doc = V.soulTeamsDocument(result ? { ...teamsCtx, local: result.local } : teamsCtx, { soul, key });
1579
1641
  } 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; }
1642
+ if (JSON_MODE) { jsonOk(result ? { ...doc, changed: result.changed } : withObservation(doc)); return; }
1581
1643
  if (result) console.log(result.changed ? `Updated the teams of ${key === "*" ? "every soul" : key} in ${shortPath(teamsCtx.localPath)}` : "Nothing to change");
1582
1644
  console.log(`${key === "*" ? "every soul" : key} on this computer: default ${doc.defaultTeam ? `${doc.defaultTeam.label} (${doc.defaultTeam.from})` : "(none)"}`);
1583
1645
  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 +1658,7 @@ async function itemsCmd(kind) {
1596
1658
  if (kind === "capabilities") await capabilityFacts(items, discovery, lock, ctx, remote);
1597
1659
  if (kind === "souls") await soulFacts(items, discovery, lock, ctx, remote);
1598
1660
  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; }
1661
+ 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
1662
  console.log(`${kind} of workspace ${workspaceName(discovery)} (${discovery.key} @ ${short(discovery.commit)})${standalone ? ` — ${standaloneNote(discovery)}` : ""}\n`);
1601
1663
  if (!items.length) console.log(" (none)");
1602
1664
  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 +1678,24 @@ async function capabilityFacts(rows, discovery, lock, ctx, remote = remoteModule
1616
1678
  for (const [id, entry] of Object.entries(lock?.packages || {})) {
1617
1679
  try { packages.set(id, await lockedPackageCapabilities(id, entry, { catalog, remote, remoteOptions })); } catch { packages.set(id, null); }
1618
1680
  }
1681
+ // Indexes built once (a scan of every member per row was O(rows × members)): the member row per key
1682
+ // (memberRowByKey); the member rows per (key, commit), in row order.
1683
+ const capOf = (member, name) => member?.capabilities.find((c) => c.name === name);
1684
+ const rowsAt = new Map();
1685
+ for (const r of rows) {
1686
+ if (r.kind === "package") continue;
1687
+ const k = `${r.repoKey}\0${r.commit}`;
1688
+ if (!rowsAt.has(k)) rowsAt.set(k, []);
1689
+ rowsAt.get(k).push(r);
1690
+ }
1619
1691
  // A member capability's fingerprint is its Git tree at the commit (one listing per member commit); a
1620
1692
  // package's is the package integrity (the lock).
1621
1693
  const trees = new Map();
1622
1694
  const treeOf = async ({ repoKey, commit }, ref, dir) => {
1623
1695
  const key = `${repoKey}\0${commit}`;
1624
1696
  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);
1697
+ const member = memberRowByKey(discovery.members, repoKey);
1698
+ const dirs = (rowsAt.get(key) || []).map((r) => capOf(member, r.name)?.path).filter(Boolean);
1627
1699
  trees.set(key, remoteModule.remoteTreeOids(ref, commit, [...new Set(dirs)], remoteOptions).catch(() => new Map()));
1628
1700
  }
1629
1701
  return (await trees.get(key)).get(dir) ?? null;
@@ -1634,7 +1706,7 @@ async function capabilityFacts(rows, discovery, lock, ctx, remote = remoteModule
1634
1706
  const read = packages.get(row.package), cap = read?.capabilities.find((c) => c.name === row.name);
1635
1707
  if (cap) ({ manifest, dir } = cap), ref = read.ref;
1636
1708
  } else {
1637
- const cap = discovery.members.find((m) => m.key === row.repoKey)?.capabilities.find((c) => c.name === row.name);
1709
+ const cap = capOf(memberRowByKey(discovery.members, row.repoKey), row.name);
1638
1710
  if (cap) { manifest = cap.manifest; dir = cap.path; ref = memberRef(discovery, remoteModule, row.repoKey); }
1639
1711
  }
1640
1712
  const provides = manifest ? await capabilityProvides({ ref, commit: row.commit, dir, manifest, remote, remoteOptions }) : { skills: null, commands: null, hooks: null };
@@ -1649,9 +1721,13 @@ async function capabilityFacts(rows, discovery, lock, ctx, remote = remoteModule
1649
1721
  * discovery and the lock without spawning (soulSpawnability). */
1650
1722
  async function soulFacts(rows, discovery, lock, ctx, remote = remoteModule) {
1651
1723
  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);
1724
+ // Indexes built once, each keeping the FIRST match as `find` did (a scan per row was O(souls × members)).
1725
+ const first = (pairs) => { const m = new Map(); for (const [k, v] of pairs) if (!m.has(k)) m.set(k, v); return m; };
1726
+ const packageSoul = first((discovery.packageSouls || []).map((p) => [p.qualifiedName, p]));
1727
+ const externalSoul = first((discovery.external || []).map((e) => e.soul).map((x) => [`${x.name}\0${x.repoKey}`, x]));
1728
+ const entryOf = (row) => row.kind === "package" ? packageSoul.get(row.qualifiedName)
1729
+ : row.kind === "external" ? externalSoul.get(`${row.name}\0${row.repoKey}`)
1730
+ : memberRowByKey(discovery.members, row.repoKey)?.souls.find((x) => x.name === row.name);
1655
1731
  for (const row of rows) {
1656
1732
  const entry = entryOf(row);
1657
1733
  row.file = { path: `${row.path}/soul.yaml`, url: remoteModule.browseUrl(row.repoKey, row.commit, `${row.path}/soul.yaml`) };
@@ -1689,7 +1765,7 @@ async function statusDrift(data) {
1689
1765
  if (!anything) return { drift: new Map(), soul: new Map(), souls, unreachable: null };
1690
1766
  const deploymentDir = dirname(ctx.path);
1691
1767
  let lock = null;
1692
- try { if (existsSync(join(deploymentDir, LOCK_FILE))) lock = readLock(deploymentDir); } catch { lock = null; }
1768
+ try { lock = readLockIfPresent(deploymentDir); } catch { lock = null; }
1693
1769
  let discovery;
1694
1770
  // The standalone view (decisions 10/25) is a discovery too: drift of a standalone
1695
1771
  // instance is computed against its member's current state, not reported "unreachable".
@@ -1707,9 +1783,12 @@ async function statusDrift(data) {
1707
1783
  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
1784
  }
1709
1785
  // The roster's soul row reflects the CURRENT member commit too (the pointer may lag a moved member).
1786
+ // First match per key, as `find` gave, from indexes built once (not a scan per agent).
1787
+ const packageSoulAt = new Map();
1788
+ for (const p of discovery.packageSouls || []) { const k = `${p.package}\0${p.path}`; if (!packageSoulAt.has(k)) packageSoulAt.set(k, p); }
1710
1789
  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);
1790
+ if (typeof stamp.package === "string") { const now = packageSoulAt.get(`${stamp.package}\0${stamp.path}`); if (now) stamp.current = now.commit; continue; }
1791
+ const member = memberRowByKey(discovery.members, stamp.repoKey);
1713
1792
  if (member && typeof member.commit === "string" && (member.confirmed || (discovery.standalone === true && member.key === discovery.key))) stamp.current = member.commit;
1714
1793
  }
1715
1794
  return { drift, soul, souls, unreachable: null };
@@ -1780,7 +1859,8 @@ async function status() {
1780
1859
  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
1860
  }
1782
1861
  }
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;
1862
+ const observation = maxAgeGiven === null ? {} : { observation: observationBlock() };
1863
+ 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
1864
  }
1785
1865
  console.log(`oats status — agents root ${shortPath(root)}\n`);
1786
1866
  if (ws?.unreachable) console.log(` workspace: unreachable (${ws.unreachable.reason}) — drift unknown\n`);
@@ -1811,8 +1891,9 @@ async function status() {
1811
1891
  const livenessWord = (i) => i.running === true ? "RUNNING" : i.running === false ? "idle" : "unknown";
1812
1892
  /** The flags `oats spawn` reads: those taking a value, and switches. `--provider` takes two words.
1813
1893
  * `--instance` is refused by a local spawn (with its replacement) but still travels to an older
1814
- * host through `--server`, whose route reads it. */
1815
- const SPAWN_VALUE_FLAGS = new Set(["agents-root", "backend", "base", "branch", "dir", "expect-decision", "harness", "idempotency-key", "instance", "launch-config", "model", "name", "parent", "purpose", "relation", "relative-root", "relative-to", "repo", "runtime", "task", "task-file", "trigger-event", "wake-cron", "wake-every", "wake-file", "wake-json", "wake-message", "wake-message-file", "wake-tz", "work", "work-dir"]);
1894
+ * host through `--server`, whose route reads it. `--max-age` reaches here only on a preview: the
1895
+ * dispatch allow-list (maxAgeRefusal) refuses it on an apply. */
1896
+ const SPAWN_VALUE_FLAGS = new Set(["agents-root", "backend", "base", "branch", "dir", "expect-decision", "harness", "idempotency-key", "instance", "launch-config", "max-age", "model", "name", "parent", "purpose", "relation", "relative-root", "relative-to", "repo", "runtime", "task", "task-file", "trigger-event", "wake-cron", "wake-every", "wake-file", "wake-json", "wake-message", "wake-message-file", "wake-tz", "work", "work-dir"]);
1816
1897
  const SPAWN_SWITCHES = new Set(["allow-child-spawns", "json", "no-child-spawns", "no-launch", "no-yolo", "preview", "yolo"]);
1817
1898
  /** Why `argv` (after `spawn`, the soul first) is not a spawn, or undefined: a positional after the
1818
1899
  * soul or a flag spawn does not read is never ignored. A value flag consumes its value exactly as
@@ -2068,8 +2149,8 @@ async function spawnCmd() {
2068
2149
  // A workspace preview may have fetched the soul's SOURCE to a temporary copy
2069
2150
  // (the deployment's cache had no entry for its commit): the result says so.
2070
2151
  if (prepared) r.soulFetched = soulFetched;
2071
- 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)" : ""}`);
2152
+ if (JSON_MODE) { jsonOk(withObservation(r)); return; }
2153
+ 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
2154
  return;
2074
2155
  }
2075
2156
  } catch (e) {
@@ -2081,7 +2162,7 @@ async function spawnCmd() {
2081
2162
  // A launch refusal (configuration, executable, environment reference,
2082
2163
  // model, harness) is a fact about the selection, not a spawn-mechanism
2083
2164
  // 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; }
2165
+ 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
2166
  // An unmet declared requirement is a fact about the soul's configuration
2086
2167
  // (with a remedy), not a spawn-mechanism failure: keep its code and details.
2087
2168
  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; }
@@ -2685,8 +2766,9 @@ async function capabilityCommand() {
2685
2766
  let activeIds;
2686
2767
  let context = process.cwd();
2687
2768
  let teamCtx, homeMeta, homeTeamCtx;
2688
- // OATS_INSTANCE_HOME is the canonical identity; the older names still count.
2689
- const instanceHome = process.env.OATS_INSTANCE_HOME || process.env.PI_AGENT_HOME || process.env.OATS_HOME;
2769
+ // OATS_INSTANCE_HOME is the canonical identity; the older names still count. With none set
2770
+ // (a harness that strips the session env), the home enclosing the cwd.
2771
+ const instanceHome = process.env.OATS_INSTANCE_HOME || process.env.PI_AGENT_HOME || process.env.OATS_HOME || enclosingInstanceHome(logicalCwd());
2690
2772
  const metaFile = instanceHome && join(instanceHome, "instance.json");
2691
2773
  // Capability-id keyed — never answer for `constructor`/`toString`. Belt and
2692
2774
  // braces: the ids come from instance.json, which spawn wrote from resolved
@@ -2782,6 +2864,7 @@ async function capabilityCommand() {
2782
2864
  // OATS_SOUL is the recorded soul or nothing: an ambient value inherited from the
2783
2865
  // invoking process names some other soul (a coordinator's own), never this one.
2784
2866
  const { OATS_SOUL: _ambientSoul, ...inherited } = process.env;
2867
+ await readSession?.closeBatches(); // no idle `git cat-file --batch` child held for the provider's whole run
2785
2868
  const r = spawnSync("node", [abs, ...rest, ...rawArgs.slice(2)], { stdio: "inherit", env: {
2786
2869
  ...inherited, OATS_CAPABILITY: m.capability,
2787
2870
  // Package-runtime boundary: dispatched commands receive the active
@@ -2803,7 +2886,9 @@ async function capabilityCommand() {
2803
2886
  } });
2804
2887
  // Child never ran (spawn error): nothing reached stdout — keep the envelope contract.
2805
2888
  if (r.error) bail("E_CAPABILITY_BROKEN", `oats ${cmd} ${sub}: ${r.error.message || r.error}`);
2806
- process.exit(r.status ?? 1);
2889
+ // A provider killed by a signal (a Ctrl-C reaches both it and us; our read session's handler
2890
+ // waits for spawnSync) exits as the shell reports a signal death: 128 + its number.
2891
+ process.exit(r.status ?? (r.signal ? 128 + (osConstants.signals[r.signal] ?? 0) : 1));
2807
2892
  }
2808
2893
  }
2809
2894
 
@@ -2861,7 +2946,7 @@ function versionCmd() {
2861
2946
  // Phase B: `instance-modules` and `spawn-provider-payload` are advertised only once spawn
2862
2947
  // runs on resolve/materialize (contract §6); a feature the binary does not implement is
2863
2948
  // 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 }));
2949
+ 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", "spawn-preview-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
2950
  return;
2866
2951
  }
2867
2952
  console.log(`@awebai/oats ${OATS_VERSION} (desktop API v1)`);
@@ -3253,6 +3338,17 @@ try {
3253
3338
  const selector = head.find((a) => /^--(deployment|resolution|artifact-set)$/.test(a));
3254
3339
  const refuse = (message, details) => { if (JSON_MODE) jsonFail("E_UNSUPPORTED_MODE", message, details); die(message); };
3255
3340
  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 });
3341
+ // --max-age (feature observe-max-age): ONE allow-list for every kernel command, here — never a
3342
+ // per-command copy. A capability namespace's argv is its provider's.
3343
+ if (kernelArgv && head.includes("--max-age")) {
3344
+ const refusal = maxAgeRefusal(cmd, head);
3345
+ if (refusal) cmdFail("E_BAD_ARGS", refusal);
3346
+ const raw = flag("max-age");
3347
+ if (raw === true) cmdFail("E_BAD_ARGS", `--max-age needs a value: whole seconds from 0 to ${remoteModule.MAX_AGE_LIMIT}`);
3348
+ 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)}`);
3349
+ maxAgeGiven = Number(raw);
3350
+ activateLocalInputs(); // observation.localRevision: every local config read from here on is recorded
3351
+ }
3256
3352
  const inherited = ["OATS_RESOLUTION", "OATS_DEPLOYMENT"].filter((k) => process.env[k]);
3257
3353
  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
3354
  if (cmd === "inspect" && head.includes("--request")) {
@@ -3329,7 +3425,8 @@ else if (cmd && !cmd.startsWith("--") && !HELP_WORDS.has(cmd) && JSON_MODE) json
3329
3425
  else {
3330
3426
  if (cmd && !HELP_WORDS.has(cmd) && !cmd.startsWith("--")) console.error(`oats: unknown command "${cmd}" — no kernel subcommand or active capability namespace matches\n`);
3331
3427
  console.log(usageText());
3332
- process.exit(cmd && !HELP_WORDS.has(cmd) ? 1 : 0);
3428
+ // exitCode, not exit(): the usage is ~20 KB, and exit() cuts off whatever a pipe has not drained yet.
3429
+ process.exitCode = cmd && !HELP_WORDS.has(cmd) ? 1 : 0;
3333
3430
  }
3334
3431
  } // end: every command but onboard
3335
3432
 
@@ -3357,6 +3454,7 @@ Usage:
3357
3454
  oats version [--json] kernel version; --json emits the
3358
3455
  Desktop CLI API v1 probe payload
3359
3456
  oats status [--json] agents, souls, running instances
3457
+ [--max-age <s>] reuse head observations up to <s> s old (below)
3360
3458
  oats server add <id> --ssh <alias> register another machine's OATS (OpenSSH alias,
3361
3459
  --workspace </abs/path> [--oats <p>] remote workspace, remote oats path; no keys stored;
3362
3460
  [--path <dir:dir>] --path = dirs prepended to the remote PATH, e.g. ~/.local/bin)
@@ -3454,13 +3552,16 @@ Usage:
3454
3552
  [--provider <capability> <key>=<value>] a provider setting for this spawn only
3455
3553
  (repeatable; dotted keys nest; recorded in
3456
3554
  instance.json providers.<capability>)
3555
+ [--preview [--max-age <s>]] decide everything, create nothing; the JSON's
3556
+ decision binds an apply (--expect-decision <rev>);
3557
+ --max-age reuses recent heads (preview only)
3457
3558
  oats retire <instance> [--force] retire an instance (window, hooks,
3458
3559
  [--self] [--delete-branch] worktree, home); --self = retire the
3459
3560
  [--keep-dir] [--json] CALLING instance: the window dies, then
3460
3561
  a detached external retirement runs
3461
3562
  oats inspect [--dir <scope>] [--soul <name> one authoritative JSON answer for a GUI: souls
3462
3563
  [--agents-root <abs>]] [--home <abs>] (harness defaults, editability, instructions),
3463
- [--json] installed capabilities with health, effective
3564
+ [--max-age <s>] [--json] installed capabilities with health, effective
3464
3565
  layer bindings and activation, declared
3465
3566
  operations with availability; --home answers the
3466
3567
  running home's recorded modules and their drift
@@ -3501,19 +3602,20 @@ Usage:
3501
3602
  | remove <id> [--dir <d>] workspace repo is the current checkout; otherwise
3502
3603
  print the line to add (the file travels through Git)
3503
3604
  oats workspace status [--dir <d>] [--json] membership table (confirmed / no-backlink /
3504
- cannot-read / backlink-elsewhere), locked packages
3605
+ [--max-age <s>] cannot-read / backlink-elsewhere), locked packages
3505
3606
  oats capabilities [--dir <d>] [--json] every capability of every confirmed member (a
3506
- private one is listed as repo-owned: usable only by
3607
+ [--max-age <s>] private one is listed as repo-owned: usable only by
3507
3608
  its own repo's souls) + the locked packages
3508
3609
  oats souls [--dir <d>] [--json] every soul of every confirmed member + external souls
3509
- (souls have no private mode), with origin
3610
+ [--max-age <s>] (souls have no private mode), with origin
3510
3611
  (member <key> @ <commit> | package <id> v<ver>) and its
3511
3612
  teams on this deployment
3512
3613
  oats teams [--json] | add <label> --team <id> [--description <d>] | remove <label>
3513
3614
  | default <label> [--dir <d>] this deployment's teams (shared + local), the
3514
- default; add/remove/default edit oats-local.yaml
3615
+ [--max-age <s>] (the read form only) default; add/remove/default edit oats-local.yaml
3515
3616
  oats soul teams <soul>|'*' [--add a,b] [--remove a,b] [--default <l> | --clear-default]
3516
3617
  [--dir <d>] [--json] which teams a soul (or every soul) belongs to here
3618
+ [--max-age <s>] (without an edit)
3517
3619
  oats instance git <instance> [--home <abs>] [--dir <d>] [--json]
3518
3620
  read-only Git observation of the instance's work
3519
3621
  tree: branch, status (renames kept), ahead/behind
@@ -3568,6 +3670,20 @@ The turn record (core — every conversation captured, searchable, replicated):
3568
3670
  oats <namespace> <command> [args…] run an operational command only when its
3569
3671
  capability is active (e.g. oats okf harvest)
3570
3672
 
3673
+ Observation reuse (feature observe-max-age):
3674
+ --max-age <seconds> on the read verbs only — status, workspace status,
3675
+ souls, capabilities, inspect --soul|--home,
3676
+ spawn --preview (feature spawn-preview-max-age),
3677
+ and the read forms of teams and soul teams —
3678
+ reuse a remote head observation up to <seconds>
3679
+ old (0–86400; 0 is live) instead of asking the
3680
+ remote again; the JSON
3681
+ then carries observation { observedAt (the oldest
3682
+ head used), reused, localRevision (a digest of
3683
+ the local configuration read) }. Refused
3684
+ (E_BAD_ARGS) by every other command, an edit form,
3685
+ a spawn apply, and with --server
3686
+
3571
3687
  Layers: ${LAYERS.join(", ")}. Workspace model v2: docs/design/2026-09-23-workspace-module-contracts.md.`;
3572
3688
  }
3573
3689
  } catch (e) {
@@ -3577,4 +3693,8 @@ Layers: ${LAYERS.join(", ")}. Workspace model v2: docs/design/2026-09-23-workspa
3577
3693
  // already names the offending file — the readers re-raise it with one.
3578
3694
  if (JSON_MODE) jsonFail(e.code, e.message);
3579
3695
  die(e.message);
3696
+ } finally {
3697
+ // The command's read session: every `git cat-file --batch` child ends before the process does.
3698
+ sayReadNotices();
3699
+ if (readSession) await readSession.close();
3580
3700
  }
@@ -355,6 +355,11 @@ for this contract. Hyphenated vendors are also excluded because translating a
355
355
  hyphen to `_` would let `aweb-evil.*` collide with names already inside
356
356
  `aweb.*`'s `AWEB_*` namespace.
357
357
 
358
+ Hook environment values must not be secrets. A codex launch also passes them
359
+ to Codex as command-line arguments (`-c shell_environment_policy.set.<NAME>=…`,
360
+ so its tool commands see them), and any local user can read those. A secret
361
+ reaches a launch through a launch configuration's environment reference.
362
+
358
363
  A manifest's `settings.<key>` may carry `hostOnly: true`. Such a
359
364
  key is a fact about the machine — a custody directory, a state root — and the
360
365
  resolver accepts it only from the deployment's own `oats-local.yaml`