@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 +1 -1
- package/bin/oats.mjs +162 -42
- package/docs/capabilities.md +5 -0
- package/docs/configuration.md +65 -8
- package/docs/design/2026-09-23-workspace-module-contracts.md +3 -2
- package/docs/desktop-cli-api.md +167 -18
- package/docs/desktop.md +16 -0
- package/docs/first-team.md +20 -2
- package/docs/implementation.md +121 -1
- package/docs/integrations.md +1 -1
- package/docs/oats-local.schema.json +2 -1
- package/docs/official-catalog.md +2 -2
- package/docs/packages.md +2 -2
- package/docs/plans/0.30-close-out.md +7 -4
- package/docs/release-notes/v0.32.0.md +137 -0
- package/docs/release-notes/v0.33.0.md +174 -0
- package/docs/souls-and-instances.md +64 -0
- package/docs/workspaces.md +1 -1
- package/lib/automations.mjs +5 -1
- package/lib/core.mjs +164 -46
- package/lib/harness-trust.mjs +139 -0
- package/lib/instance-inspect.mjs +18 -3
- package/lib/instance-resolution.mjs +3 -3
- package/lib/local-inputs.mjs +46 -0
- package/lib/materialize.mjs +3 -2
- package/lib/packages.mjs +26 -6
- package/lib/process-group.mjs +54 -0
- package/lib/remote.mjs +1363 -115
- package/lib/resolve.mjs +23 -7
- package/lib/servers.mjs +7 -0
- package/lib/workspace.mjs +163 -56
- package/package-catalog.json +2 -2
- package/package.json +1 -1
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 =
|
|
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
|
|
1626
|
-
const dirs =
|
|
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
|
|
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
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
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 {
|
|
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 =
|
|
1712
|
-
const member = discovery.members
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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]
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/docs/capabilities.md
CHANGED
|
@@ -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`
|