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