@awebai/oats 0.22.17 → 0.23.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 +7 -2
- package/bin/oats.mjs +365 -31
- package/docs/configuration.md +65 -0
- package/docs/design/2026-09-07-mobile-agent-management-proposal.md +228 -0
- package/docs/design/2026-09-08-expert-assisted-deployment-proposal.md +558 -0
- package/docs/design/2026-09-13-knowledge-and-memory-direction.md +744 -0
- package/docs/design/2026-09-13-knowledge-implementation.md +127 -0
- package/docs/design/2026-09-13-knowledge-location-contract.md +340 -0
- package/docs/design/launch-configurations.md +164 -0
- package/docs/design/package-runtime-api.md +177 -3
- package/docs/desktop-cli-api.md +68 -2
- package/docs/desktop-instance-start.md +39 -3
- package/docs/execution-targets.md +16 -0
- package/docs/knowledge-capability-authoring.md +98 -0
- package/docs/knowledge-reference/acceptance.md +108 -0
- package/docs/knowledge-reference/adoption.md +61 -0
- package/docs/knowledge-reference/harvester.md +107 -0
- package/docs/knowledge-reference/model.md +84 -0
- package/docs/knowledge-reference/package-craft.md +126 -0
- package/docs/knowledge-reference/provider-mapping.md +77 -0
- package/docs/knowledge-reference/reader-capture.md +87 -0
- package/docs/knowledge-theory.md +20 -6
- package/docs/layers.md +8 -7
- package/docs/oats-config.schema.json +33 -2
- package/docs/release-notes/v0.22.18.md +101 -0
- package/docs/release-notes/v0.22.19.md +115 -0
- package/docs/release-notes/v0.23.0.md +93 -0
- package/docs/souls-and-instances.md +18 -1
- package/injects/work-directory.md +18 -0
- package/lib/core.mjs +1109 -187
- package/lib/schedule.mjs +12 -2
- package/lib/servers.mjs +89 -4
- package/package.json +2 -2
- package/packages/record/README.md +19 -0
- package/packages/record/bin/capture.mjs +144 -53
- package/packages/record/bin/recall.mjs +17 -11
- package/packages/record/bin/record-native-start.mjs +11 -0
- package/packages/record/lib/capture-cc.mjs +82 -27
- package/packages/record/lib/capture-lock.mjs +81 -5
- package/packages/record/lib/formats.mjs +108 -21
- package/packages/record/lib/native-history.mjs +87 -0
- package/packages/record/lib/session-roots.mjs +90 -0
- package/packages/record/lib/session-snapshot.mjs +61 -0
- package/packages/record/lib/sessions-for-home.mjs +88 -56
- package/skills/oats/SKILL.md +3 -1
package/bin/oats.mjs
CHANGED
|
@@ -21,7 +21,7 @@ import { createHash } from "node:crypto";
|
|
|
21
21
|
import { fileURLToPath } from "node:url";
|
|
22
22
|
import { enableTmuxMouse, tmuxConfigPath, tmuxMouseEnabled } from "../lib/tmux-config.mjs";
|
|
23
23
|
import {
|
|
24
|
-
LAYERS, LEGACY_HOME_CAPABILITIES_DIR, OATS_LOCK_FILE, OATS_VERSION, OAS_SCOPE_REMEDY, RETIRED_CAPABILITIES, detectOasScopes, retiredCapabilityReason, configChain, configCapabilityEntries, manifestOperations,
|
|
24
|
+
LAYERS, WORK_MODES, LEGACY_HOME_CAPABILITIES_DIR, OATS_LOCK_FILE, OATS_VERSION, OAS_SCOPE_REMEDY, RETIRED_CAPABILITIES, detectOasScopes, retiredCapabilityReason, configChain, configCapabilityEntries, manifestOperations,
|
|
25
25
|
acquireCapability, restoreCapabilities, marketplaceCapabilities,
|
|
26
26
|
capabilityManifests, capabilityManifest, capabilityMissingRequires, capabilityIntegrity, capabilityTrust, capabilityExecutablePath,
|
|
27
27
|
readCapabilityLocks, writeCapabilityLock,
|
|
@@ -32,7 +32,7 @@ import {
|
|
|
32
32
|
resolveOatsConfig, resolveWorkMode, composeInstanceAgentsMd, parseYamlNested, assertSafeConfigValue, assertSafeConfigWriteKey, stripInternalAnnotations, withConfigFile, packagedInject, teamAgentRoots,
|
|
33
33
|
findTeamAgent, findTeamInstance, findCapabilityAgent, findInstanceHome, listCapabilityAgents, workspaceOf,
|
|
34
34
|
ensureRoot, findRoot, findAgent, listAgents, listInstances, listAgentDefs, createAgent as coreCreateAgent,
|
|
35
|
-
spawnInstance, retireInstance, inspectInstanceSession, inputInstanceSession, attachInstanceSession, startInstanceSession, upsertLocalAgent, defaultRepo, RELATIONS,
|
|
35
|
+
spawnInstance, retireInstance, inspectInstanceSession, inputInstanceSession, attachInstanceSession, startInstanceSession, upsertLocalAgent, defaultRepo, RELATIONS, validateLaunchConfig, resolveLaunchSelection, resolveLaunchExecutable, checkLaunchExecutable, missingLaunchEnvRefs, renderLaunchRecipe, describeLaunchCommand, redactLaunchRecipe, LAUNCH_RUNTIMES, LAUNCH_RECIPE_VERSION, parseLaunchCommand, resolveYolo, planLaunch, redactLaunchCommand, restartInstanceSession,
|
|
36
36
|
} from "../lib/core.mjs";
|
|
37
37
|
import {
|
|
38
38
|
aggregateMissingRequirements, applyFromOasScope, beginRunJournal, discoverMigrationScopes, discoverOasScopes, discoverWorkspaceScopes, planFromOasScope,
|
|
@@ -40,7 +40,7 @@ import {
|
|
|
40
40
|
assertNoSymlinkedParents, copyFileAtomic, writeFileAtomic,
|
|
41
41
|
runRequirementInstall, selectConfigTemplate, validateConfigTemplate, writeAdoptedTemplate,
|
|
42
42
|
} from "../lib/packages.mjs";
|
|
43
|
-
import { attachArgv, checkRemote, forgetSnapshot, getServer, inspectRemote, startRemote, scheduleRemote, listSnapshots, readServers, rosterGroups, routeCommand, targetOf, validateServer, writeServers, SERVERS_FILE } from "../lib/servers.mjs";
|
|
43
|
+
import { attachArgv, checkRemote, forgetSnapshot, getServer, inspectRemote, startRemote, restartRemote, launchConfigRemote, scheduleRemote, listSnapshots, readServers, rosterGroups, routeCommand, targetOf, validateServer, writeServers, SERVERS_FILE } from "../lib/servers.mjs";
|
|
44
44
|
import { spawnSync as spawnSyncProc } from "node:child_process";
|
|
45
45
|
import { parseEnvelopeText, scheduleScopeOf, listSchedules, describe as describeSchedule, addSchedule, updateSchedule, setEnabled as setScheduleEnabled, removeSchedule, runNow as runScheduleNow, reconcile as reconcileSchedule, tickHost, tickWorkspace, registerWorkspace, unregisterWorkspace, readRegistry, schedulerStatus, saveWakeForHome, removeWakeForHome, wakeFromFlags, withHostLock, scheduleError, SCHEDULE_API } from "../lib/schedule.mjs";
|
|
46
46
|
import { hostUnitStatus, installHostUnit, uninstallHostUnit } from "../lib/schedule-host.mjs";
|
|
@@ -359,7 +359,7 @@ function agentsRootOfHome(home) {
|
|
|
359
359
|
const base = dirname(agentDir);
|
|
360
360
|
return basename(base) === "local-agents" ? join(dirname(base), "agents") : base;
|
|
361
361
|
}
|
|
362
|
-
const SOUL_FIELDS = ["runtime", "model", "yolo", "backend", "description"];
|
|
362
|
+
const SOUL_FIELDS = ["runtime", "model", "yolo", "backend", "description", "launch-config"];
|
|
363
363
|
const realOrResolved = (p) => { try { return realpathSync(p); } catch { return resolve(p); } };
|
|
364
364
|
/** Every soul of a scope: persistent and local souls of every agents root in
|
|
365
365
|
* scope, plus packaged souls (read-only). One enumeration for inspect and
|
|
@@ -434,7 +434,7 @@ function soulEntry(soul, root, { capability } = {}) {
|
|
|
434
434
|
return {
|
|
435
435
|
name: soul.name, kind: packaged ? "capability" : (soul.kind || "persistent"), capability: capability || null,
|
|
436
436
|
type: soul.type ?? null, description: soul.description ?? null, repo: soul.repo ?? null, work: soul.work || "checkout",
|
|
437
|
-
runtime: soul.runtime || "pi", model: soul.model ?? null, yolo: soul.yolo === true || soul.yolo === "true" ? true : soul.yolo === false || soul.yolo === "false" ? false : null, backend: soul.backend ?? null,
|
|
437
|
+
runtime: soul.runtime || "pi", model: soul.model ?? null, yolo: soul.yolo === true || soul.yolo === "true" ? true : soul.yolo === false || soul.yolo === "false" ? false : null, launchConfig: soul["launch-config"] ?? null, backend: soul.backend ?? null,
|
|
438
438
|
agentsRoot: root, dir: packaged ? soulDir : dir, soulFile: join(soulDir, "soul.yaml"), instructionsFile: join(soulDir, "AGENTS.md"),
|
|
439
439
|
editable: packaged
|
|
440
440
|
? { fields: [], instructions: false, reason: `packaged soul from capability ${capability}: edit the package and update it; scoped bindings still apply through oats use` }
|
|
@@ -830,6 +830,9 @@ async function soulCmd() {
|
|
|
830
830
|
if (has("yolo")) changes.yolo = true;
|
|
831
831
|
if (has("no-yolo")) changes.yolo = false;
|
|
832
832
|
if (has("backend")) { const v = val("backend"); if (!["tmux", "herdr"].includes(v)) bail("E_BAD_ARGS", "--backend must be tmux or herdr"); changes.backend = v; }
|
|
833
|
+
if (has("launch-config") && has("no-launch-config")) bail("E_BAD_ARGS", "choose --launch-config <name> or --no-launch-config, not both");
|
|
834
|
+
if (has("launch-config")) { const v = val("launch-config"); if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(v)) bail("E_BAD_ARGS", "--launch-config needs a configuration name (letters, digits, dot, underscore, dash)"); changes["launch-config"] = v; }
|
|
835
|
+
if (has("no-launch-config")) changes["launch-config"] = null;
|
|
833
836
|
if (has("description") && has("no-description")) bail("E_BAD_ARGS", "choose --description <d> or --no-description, not both");
|
|
834
837
|
if (has("description")) changes.description = assertSafeConfigValue(val("description"), "--description");
|
|
835
838
|
if (has("no-description")) changes.description = null;
|
|
@@ -855,9 +858,9 @@ async function soulCmd() {
|
|
|
855
858
|
// the intent, and a TTY was refused above.
|
|
856
859
|
instructions = bytes;
|
|
857
860
|
}
|
|
858
|
-
if (!Object.keys(changes).length && !instructions) bail("E_BAD_ARGS", "nothing to set: pass at least one of --runtime, --model/--no-model, --yolo/--no-yolo, --backend, --description/--no-description, --instructions-file");
|
|
861
|
+
if (!Object.keys(changes).length && !instructions) bail("E_BAD_ARGS", "nothing to set: pass at least one of --runtime, --model/--no-model, --yolo/--no-yolo, --backend, --launch-config/--no-launch-config, --description/--no-description, --instructions-file");
|
|
859
862
|
for (const f of Object.keys(changes)) if (!soul.editable.fields.includes(f)) bail("E_BAD_ARGS", `${f} is not an editable field of ${name}`);
|
|
860
|
-
const before = { runtime: soul.runtime, model: soul.model, yolo: soul.yolo, backend: soul.backend, description: soul.description };
|
|
863
|
+
const before = { runtime: soul.runtime, model: soul.model, yolo: soul.yolo, backend: soul.backend, description: soul.description, launchConfig: soul.launchConfig };
|
|
861
864
|
// soul.yaml: replace or append `key: value` lines in place; a cleared
|
|
862
865
|
// field's line is removed; nothing else in the file moves.
|
|
863
866
|
let yamlText = "";
|
|
@@ -985,7 +988,7 @@ function doctor(dir) {
|
|
|
985
988
|
if (r.injects.length === 0) console.log(" (none)");
|
|
986
989
|
for (const inj of r.injects) console.log(` ${inj.source}: ${shortPath(inj.file)}`);
|
|
987
990
|
|
|
988
|
-
for (const mode of
|
|
991
|
+
for (const mode of WORK_MODES) {
|
|
989
992
|
const wm = resolveWorkMode(ctx, mode);
|
|
990
993
|
console.log(`\nWork mode ${mode}: inject ${wm.inject ? shortPath(wm.inject) : "none"}${wm.setup ? `, setup ${shortPath(wm.setup)}` : ""}`);
|
|
991
994
|
}
|
|
@@ -1173,6 +1176,255 @@ function replaceCapabilitiesBlock(text, caps) {
|
|
|
1173
1176
|
}
|
|
1174
1177
|
return [...lines.slice(0, start), ...serialized.replace(/\n$/, "").split("\n"), "", ...lines.slice(end)].join("\n").replace(/\n{3,}/g, "\n\n").replace(/\n*$/, "\n");
|
|
1175
1178
|
}
|
|
1179
|
+
/** Replace (or append, or drop with "") the top-level launch-configs block:
|
|
1180
|
+
* the span from its key line (bare, quoted, or the inline `launch-configs: {...}`
|
|
1181
|
+
* form) to the next top-level line is replaced; every byte before and after
|
|
1182
|
+
* that span stays exactly as it was. Two declarations of the key are refused
|
|
1183
|
+
* rather than guessed at. */
|
|
1184
|
+
function replaceLaunchConfigsBlock(text, serialized) {
|
|
1185
|
+
const keyLine = /^(["']?)launch-configs\1:(\s*(?:#.*)?|\s+\S.*)?$/;
|
|
1186
|
+
const lines = text.split("\n");
|
|
1187
|
+
const starts = lines.map((l, i) => keyLine.test(l) ? i : -1).filter((i) => i >= 0);
|
|
1188
|
+
if (starts.length > 1) throw Object.assign(new Error(`oats-config.yaml declares launch-configs ${starts.length} times (lines ${starts.map((i) => i + 1).join(", ")}); keep one`), { code: "E_CONFIG_BROKEN" });
|
|
1189
|
+
const block = serialized ? serialized.replace(/\n$/, "").split("\n") : [];
|
|
1190
|
+
if (!starts.length) {
|
|
1191
|
+
if (!serialized) return text;
|
|
1192
|
+
const sep = text === "" ? "" : text.endsWith("\n") ? "\n" : "\n\n"; // always its own blank separator, which removal takes back
|
|
1193
|
+
return text + sep + serialized;
|
|
1194
|
+
}
|
|
1195
|
+
const start = starts[0];
|
|
1196
|
+
// The block runs to the next real top-level key (a column-zero line that
|
|
1197
|
+
// is not a comment). Blank lines and column-zero comments directly ahead
|
|
1198
|
+
// of that key, or at the end of the file, are not part of it and stay
|
|
1199
|
+
// where they are; a column-zero comment followed by more indented entries
|
|
1200
|
+
// is inside the block (and is regenerated away with it).
|
|
1201
|
+
let end = lines.length;
|
|
1202
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
1203
|
+
if (lines[i] !== "" && !/^\s/.test(lines[i]) && !lines[i].startsWith("#")) { end = i; break; }
|
|
1204
|
+
}
|
|
1205
|
+
while (end > start + 1 && (lines[end - 1] === "" || lines[end - 1].startsWith("#"))) end--;
|
|
1206
|
+
const tail = lines.slice(end);
|
|
1207
|
+
if (!tail.length) tail.push(""); // the block ended the file: the result still ends with a newline
|
|
1208
|
+
let prefixEnd = start;
|
|
1209
|
+
// Dropping the block drops the one blank line that separated it (before it
|
|
1210
|
+
// when it was appended, else after it); replacing it keeps one blank line
|
|
1211
|
+
// between the block and what follows.
|
|
1212
|
+
if (!block.length) { if (start > 0 && lines[start - 1] === "") prefixEnd = start - 1; else if (tail[0] === "" && tail.length > 1) tail.shift(); }
|
|
1213
|
+
const replaced = [...lines.slice(0, prefixEnd), ...block];
|
|
1214
|
+
if (block.length && tail[0] !== "") replaced.push("");
|
|
1215
|
+
return [...replaced, ...tail].join("\n");
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
// ---------- launch configurations ----------
|
|
1219
|
+
const yamlQuoted = (v) => JSON.stringify(String(v));
|
|
1220
|
+
/** The `launch-configs:` block, names sorted, every string double-quoted
|
|
1221
|
+
* with JSON escapes (read back by the same rules), lists as block
|
|
1222
|
+
* sequences: spaces, quotes, commas and metacharacters round-trip exactly. */
|
|
1223
|
+
function serializeLaunchConfigs(map) {
|
|
1224
|
+
const names = Object.keys(map).sort();
|
|
1225
|
+
if (!names.length) return "";
|
|
1226
|
+
const lines = ["launch-configs:"];
|
|
1227
|
+
for (const name of names) {
|
|
1228
|
+
const e = map[name];
|
|
1229
|
+
lines.push(` ${name}:`, ` runtime: ${e.runtime}`);
|
|
1230
|
+
if (e.executable !== undefined) lines.push(` executable: ${yamlQuoted(e.executable)}`);
|
|
1231
|
+
if (e.args?.length) { lines.push(" args:"); for (const a of e.args) lines.push(` - ${yamlQuoted(a)}`); }
|
|
1232
|
+
const envNames = Object.keys(e.env || {}).sort();
|
|
1233
|
+
if (envNames.length) {
|
|
1234
|
+
lines.push(" env:");
|
|
1235
|
+
for (const n of envNames) { const v = e.env[n]; if (typeof v === "string") lines.push(` ${n}: ${yamlQuoted(v)}`); else lines.push(` ${n}:`, ` fromEnv: ${v.fromEnv}`); }
|
|
1236
|
+
}
|
|
1237
|
+
if (e.model !== undefined) lines.push(` model: ${yamlQuoted(e.model)}`);
|
|
1238
|
+
if (e.yolo !== undefined) lines.push(` yolo: ${e.yolo}`);
|
|
1239
|
+
}
|
|
1240
|
+
return lines.join("\n") + "\n";
|
|
1241
|
+
}
|
|
1242
|
+
/** Only the declared keys, in canonical order, from a validated entry. */
|
|
1243
|
+
function normalizeLaunchConfig(e) {
|
|
1244
|
+
return {
|
|
1245
|
+
runtime: e.runtime,
|
|
1246
|
+
...(e.executable !== undefined ? { executable: e.executable } : {}),
|
|
1247
|
+
...(e.args?.length ? { args: [...e.args] } : {}),
|
|
1248
|
+
...(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 }])) } : {}),
|
|
1249
|
+
...(e.model !== undefined ? { model: e.model } : {}),
|
|
1250
|
+
...(e.yolo !== undefined ? { yolo: e.yolo } : {}),
|
|
1251
|
+
};
|
|
1252
|
+
}
|
|
1253
|
+
function readLaunchConfigsModel(file) {
|
|
1254
|
+
if (!existsSync(file)) return {};
|
|
1255
|
+
const cfg = withConfigFile(file, () => parseYamlNested(readFileSync(file, "utf8")));
|
|
1256
|
+
const map = cfg["launch-configs"] || {};
|
|
1257
|
+
for (const [name, entry] of Object.entries(map)) validateLaunchConfig(name, entry, file);
|
|
1258
|
+
const out = Object.create(null); // a name may be "constructor": membership is own only
|
|
1259
|
+
for (const [n, e] of Object.entries(map)) out[n] = normalizeLaunchConfig(e);
|
|
1260
|
+
return out;
|
|
1261
|
+
}
|
|
1262
|
+
/** What a GUI or an operator sees of one configuration. Environment values
|
|
1263
|
+
* never leave the file: a literal is answered as {redacted: true} (literals
|
|
1264
|
+
* are non-secret by contract, but no value is shown anywhere) and a
|
|
1265
|
+
* reference as {fromEnv: NAME}. An editor keeps a literal it cannot see with
|
|
1266
|
+
* `set --keep-env`. */
|
|
1267
|
+
function publicLaunchConfig(e, extra = {}) {
|
|
1268
|
+
const env = Object.fromEntries(Object.keys(e.env || {}).sort().map((n) => [n, typeof e.env[n] === "string" ? { redacted: true } : { fromEnv: e.env[n].fromEnv }]));
|
|
1269
|
+
return { runtime: e.runtime, executable: e.executable ?? null, args: [...(e.args || [])], env, model: e.model ?? null, yolo: e.yolo ?? null, ...extra };
|
|
1270
|
+
}
|
|
1271
|
+
/** The scope a launch-config command reads: --dir (or cwd), a running
|
|
1272
|
+
* home's recorded context (--home), or a soul's own member context
|
|
1273
|
+
* (--soul, with --dir/--agents-root as inspect takes them). */
|
|
1274
|
+
function launchConfigContext(bail) {
|
|
1275
|
+
const homeFlag = flag("home");
|
|
1276
|
+
const soulFlag = flag("soul");
|
|
1277
|
+
if (homeFlag !== undefined && soulFlag !== undefined) bail("E_BAD_ARGS", "choose --home or --soul, not both");
|
|
1278
|
+
if (homeFlag !== undefined) {
|
|
1279
|
+
if (homeFlag === true || !isAbsolute(String(homeFlag))) bail("E_BAD_ARGS", "--home needs an absolute instance home");
|
|
1280
|
+
const home = realOrResolved(String(homeFlag));
|
|
1281
|
+
let meta;
|
|
1282
|
+
try { meta = JSON.parse(readFileSync(join(home, "instance.json"), "utf8")); } catch (e) { bail("E_HOME_UNKNOWN", `${home} is not an OATS instance home (${e.message})`); }
|
|
1283
|
+
const ctx = homeContexts(home, meta)[0];
|
|
1284
|
+
return { context: ctx, selected: { home, instance: meta.instance || null } };
|
|
1285
|
+
}
|
|
1286
|
+
const ctx = dirFlag();
|
|
1287
|
+
if (soulFlag !== undefined) {
|
|
1288
|
+
if (soulFlag === true) bail("E_BAD_ARGS", "--soul needs a soul name");
|
|
1289
|
+
const agentsRootFlag = flag("agents-root");
|
|
1290
|
+
if (agentsRootFlag === true) bail("E_BAD_ARGS", "--agents-root needs an absolute agents directory");
|
|
1291
|
+
let r;
|
|
1292
|
+
try { r = resolveOatsConfig(ctx); } catch (e) { bail(e.code || "E_CONFIG_BROKEN", e.message); }
|
|
1293
|
+
let soul;
|
|
1294
|
+
try { soul = selectSoul(scopeSouls(ctx, r).souls, String(soulFlag), agentsRootFlag, ctx); } catch (e) { bail(e.code || "E_SOUL_UNKNOWN", e.message); }
|
|
1295
|
+
return { context: memberContextOf(soul, ctx, flag("dir") !== undefined, bail), selected: { soul: soul.name, agentsRoot: soul.agentsRoot } };
|
|
1296
|
+
}
|
|
1297
|
+
return { context: ctx, selected: null };
|
|
1298
|
+
}
|
|
1299
|
+
/** oats launch-config preview: what a start of a home (or a new instance of
|
|
1300
|
+
* a soul) would run under a selection, resolved against the current scoped
|
|
1301
|
+
* configuration, preflighted, read-only; environment values withheld and
|
|
1302
|
+
* the prompt named, never the TASK body. */
|
|
1303
|
+
function launchPreview(bail) {
|
|
1304
|
+
const sel = { launchConfig: flag("launch-config"), runtime: flag("runtime"), model: flag("model"), yolo: yoloFlag() };
|
|
1305
|
+
for (const k of ["launch-config", "runtime", "model"]) if (flag(k) === true) bail("E_BAD_ARGS", `--${k} needs a value`);
|
|
1306
|
+
if (sel.runtime !== undefined && !LAUNCH_RUNTIMES.includes(sel.runtime)) bail("E_BAD_ARGS", `--runtime must be one of ${LAUNCH_RUNTIMES.join(", ")}`);
|
|
1307
|
+
const { context, selected } = launchConfigContext(bail);
|
|
1308
|
+
if (!selected) bail("E_BAD_ARGS", "preview needs --home <abs> (an existing instance) or --soul <name> [--dir <scope>] (a new instance)");
|
|
1309
|
+
const selectionGiven = sel.launchConfig !== undefined || sel.runtime !== undefined || sel.model !== undefined || sel.yolo !== undefined;
|
|
1310
|
+
let meta = null, agentLike, home, instance;
|
|
1311
|
+
if (selected.home) {
|
|
1312
|
+
home = selected.home;
|
|
1313
|
+
try { meta = JSON.parse(readFileSync(join(home, "instance.json"), "utf8")); } catch (e) { bail("E_HOME_UNKNOWN", `${home}: ${e.message}`); }
|
|
1314
|
+
instance = meta.instance || basename(home);
|
|
1315
|
+
if (!(meta.launch && typeof meta.launch === "object") && !selectionGiven) {
|
|
1316
|
+
// A home that predates recipes, asked nothing: its frozen command is
|
|
1317
|
+
// described as is. Under a selection it goes through the planner,
|
|
1318
|
+
// whose narrow conversion is the one session restart uses.
|
|
1319
|
+
let d;
|
|
1320
|
+
try { d = describeLaunchCommand(meta.command); } catch (e) { bail(e.code || "E_LAUNCH_COMMAND_UNSUPPORTED", e.message); }
|
|
1321
|
+
jsonOk({ context, selected, selection: { source: "frozen-command", launchConfig: null, runtime: null, model: null, yolo: null }, runtime: meta.runtime, 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; conversion on restart" }], ok: true });
|
|
1322
|
+
return;
|
|
1323
|
+
}
|
|
1324
|
+
const agentsRoot = agentsRootOfHome(home);
|
|
1325
|
+
const agent = (() => { try { return findAgent(agentsRoot, meta.agent); } catch { return undefined; } })();
|
|
1326
|
+
agentLike = agent || { runtime: meta.runtime, model: meta.model, yolo: meta.yolo };
|
|
1327
|
+
} else {
|
|
1328
|
+
let r0;
|
|
1329
|
+
try { r0 = resolveOatsConfig(context); } catch (e) { bail(e.code || "E_CONFIG_BROKEN", e.message); }
|
|
1330
|
+
const soul = scopeSouls(context, r0).souls.find((x) => x.name === selected.soul && x.agentsRoot === selected.agentsRoot);
|
|
1331
|
+
agentLike = { runtime: soul.runtime, model: soul.model, yolo: soul.yolo, "launch-config": soul.launchConfig };
|
|
1332
|
+
instance = `${soul.name}-<purpose>`; home = join(selected.agentsRoot, soul.name, "instances", instance);
|
|
1333
|
+
}
|
|
1334
|
+
let r;
|
|
1335
|
+
try { r = resolveOatsConfig(context, selected.soul || meta?.agent); } catch (e) { bail(e.code || "E_CONFIG_BROKEN", e.message); }
|
|
1336
|
+
// The same planner a start uses, in preview mode: failed checks are listed, nothing is touched.
|
|
1337
|
+
let plan;
|
|
1338
|
+
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); }
|
|
1339
|
+
const { recipe } = plan;
|
|
1340
|
+
const command = renderLaunchRecipe(recipe, { home, instance, redact: true });
|
|
1341
|
+
const d = describeLaunchCommand(command);
|
|
1342
|
+
const environment = d.environment.map((e) => e.reference && recipe.env[e.name]?.fromEnv ? { name: e.name, fromEnv: recipe.env[e.name].fromEnv } : e);
|
|
1343
|
+
jsonOk({ context, selected, selection: { source: plan.selectionSource, launchConfig: recipe.launchConfig, runtime: sel.runtime ?? null, model: sel.model ?? null, yolo: sel.yolo ?? null }, runtime: plan.runtime, 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 });
|
|
1344
|
+
}
|
|
1345
|
+
async function launchConfigCmd() {
|
|
1346
|
+
const bail = (code, msg) => (JSON_MODE ? jsonFail(code, msg) : die(msg));
|
|
1347
|
+
dropAmbientRoot();
|
|
1348
|
+
const sub = args[1];
|
|
1349
|
+
const usage = "usage: oats launch-config list [--dir <scope> | --home <abs> | --soul <name> [--dir <scope>] [--agents-root <abs>]] [--json] | set <name> --file <json> [--keep-env] [--dir <scope>] [--json] | remove <name> [--dir <scope>] [--json] | preview (--home <abs> | --soul <name> [--dir <scope>]) [--launch-config <name>|none] [--runtime r] [--model m] [--yolo|--no-yolo] --json";
|
|
1350
|
+
if (sub === "preview") { launchPreview(bail); return; }
|
|
1351
|
+
if (!["list", "set", "remove"].includes(sub)) bail("E_USAGE", usage);
|
|
1352
|
+
const { context: dir, selected } = sub === "list" ? launchConfigContext(bail) : { context: dirFlag(), selected: null };
|
|
1353
|
+
if (sub !== "list" && (flag("home") !== undefined || flag("soul") !== undefined)) bail("E_BAD_ARGS", `launch-config ${sub} writes one scope's oats-config.yaml: address it with --dir, not --home or --soul`);
|
|
1354
|
+
const level = levelOf(dir);
|
|
1355
|
+
const file = join(dir, "oats-config.yaml");
|
|
1356
|
+
const effective = () => {
|
|
1357
|
+
const r = resolveOatsConfig(dir);
|
|
1358
|
+
return Object.values(r.launchConfigs || {}).sort((a, b) => a.name.localeCompare(b.name)).map((e) => ({ name: e.name, ...publicLaunchConfig(e, { source: e.source, shadows: e.shadows }) }));
|
|
1359
|
+
};
|
|
1360
|
+
if (sub === "list") {
|
|
1361
|
+
let configurations;
|
|
1362
|
+
try { configurations = effective(); } catch (e) { bail(e.code || "E_CONFIG_BROKEN", e.message); }
|
|
1363
|
+
if (JSON_MODE) { jsonOk({ context: dir, level, file: existsSync(file) ? file : null, selected, configurations }); return; }
|
|
1364
|
+
if (!configurations.length) { console.log(`No launch configurations are effective at ${dir}`); return; }
|
|
1365
|
+
for (const c of configurations) {
|
|
1366
|
+
const env = Object.entries(c.env).map(([n, v]) => v.fromEnv ? `${n}=$${v.fromEnv}` : `${n}=<redacted>`).join(" ");
|
|
1367
|
+
console.log(`${c.name}: ${c.runtime}${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}` : ""} (${c.source}${c.shadows.length ? `; shadows ${c.shadows.join(", ")}` : ""})`);
|
|
1368
|
+
}
|
|
1369
|
+
return;
|
|
1370
|
+
}
|
|
1371
|
+
const name = args[2];
|
|
1372
|
+
if (!name || name.startsWith("--")) bail("E_BAD_ARGS", `launch-config ${sub} needs a configuration name`);
|
|
1373
|
+
const text = existsSync(file) ? readFileSync(file, "utf8") : `name: ${scaffoldConfigName(dir)}\n`;
|
|
1374
|
+
let model;
|
|
1375
|
+
try { model = readLaunchConfigsModel(file); } catch (e) { bail(e.code || "E_CONFIG_BROKEN", e.message); }
|
|
1376
|
+
const declaredHere = Object.hasOwn(model, name);
|
|
1377
|
+
const before = declaredHere ? publicLaunchConfig(model[name]) : null;
|
|
1378
|
+
if (sub === "remove") {
|
|
1379
|
+
if (!declaredHere) bail("E_LAUNCH_CONFIG_UNKNOWN", `${name} is not declared at ${level} level (${shortPath(file)}); an inherited configuration is removed at the scope that declares it`);
|
|
1380
|
+
delete model[name];
|
|
1381
|
+
} else {
|
|
1382
|
+
const f = flag("file");
|
|
1383
|
+
if (!f || f === true) bail("E_BAD_ARGS", "launch-config set needs --file <json> (an object with runtime and optional executable, args, env, model, yolo)");
|
|
1384
|
+
let entry;
|
|
1385
|
+
// A parse error is reported without the parser's text: its message can
|
|
1386
|
+
// quote the document, and a definition may carry environment literals.
|
|
1387
|
+
let raw;
|
|
1388
|
+
if (f === "-") {
|
|
1389
|
+
// The routed form: the definition's bytes arrive on stdin (the ssh
|
|
1390
|
+
// transport); no local file name crosses the wire.
|
|
1391
|
+
if (process.stdin.isTTY) bail("E_BAD_ARGS", "--file - reads the definition from stdin");
|
|
1392
|
+
try { raw = (await readStreamBounded(process.stdin, INSPECT_TEXT_CAP)).toString("utf8"); } catch (e) { bail(e.code || "E_BAD_ARGS", e.message); }
|
|
1393
|
+
} else {
|
|
1394
|
+
try { raw = readFileSync(f, "utf8"); } catch (e) { bail("E_BAD_ARGS", `--file ${f}: ${e.code === "ENOENT" ? "no such file" : e.code || "cannot read"}`); }
|
|
1395
|
+
}
|
|
1396
|
+
try { entry = JSON.parse(raw); } catch { bail("E_BAD_ARGS", `--file ${f} is not valid JSON (one object with runtime and optional executable, args, env, model, yolo)`); }
|
|
1397
|
+
if (args.includes("--keep-env")) {
|
|
1398
|
+
// An editor that saw only redacted values keeps the environment of the
|
|
1399
|
+
// definition EFFECTIVE at this scope for that name (this scope's own, or
|
|
1400
|
+
// the inherited one it is overriding): a one-time copy into the complete
|
|
1401
|
+
// replacement entry, not inheritance; whole-entry shadowing stays.
|
|
1402
|
+
if (entry && typeof entry === "object" && entry.env !== undefined) bail("E_BAD_ARGS", "--keep-env keeps the environment of the effective definition; omit env from --file");
|
|
1403
|
+
let current;
|
|
1404
|
+
try { const all = resolveOatsConfig(dir).launchConfigs || {}; current = Object.hasOwn(all, name) ? all[name] : undefined; } catch (e) { bail(e.code || "E_CONFIG_BROKEN", e.message); }
|
|
1405
|
+
if (!current) bail("E_LAUNCH_CONFIG_UNKNOWN", `--keep-env: no launch configuration ${name} is effective at ${dir}, so there is no environment to keep; declare it with env`);
|
|
1406
|
+
if (entry && typeof entry === "object" && Object.keys(current.env || {}).length) entry.env = { ...current.env };
|
|
1407
|
+
}
|
|
1408
|
+
try { validateLaunchConfig(name, entry, `--file ${f}`); } catch (e) { bail(e.code || "E_LAUNCH_CONFIG_INVALID", e.message); }
|
|
1409
|
+
model[name] = normalizeLaunchConfig(entry);
|
|
1410
|
+
}
|
|
1411
|
+
let next;
|
|
1412
|
+
try { next = replaceLaunchConfigsBlock(text, serializeLaunchConfigs(model)); } catch (e) { bail(e.code || "E_CONFIG_BROKEN", `${e.message}; nothing was written`); }
|
|
1413
|
+
// What is written must read back as exactly what was asked, by the kernel's
|
|
1414
|
+
// own reader, before a byte of the file changes.
|
|
1415
|
+
let readBack;
|
|
1416
|
+
try { readBack = parseYamlNested(next)["launch-configs"] || {}; } catch (e) { bail("E_LAUNCH_CONFIG_INVALID", `the rewritten block does not parse: ${e.message}; nothing was written`); }
|
|
1417
|
+
const canonical = (m) => JSON.stringify(Object.keys(m).sort().map((n) => [n, normalizeLaunchConfig(m[n])]));
|
|
1418
|
+
const same = canonical(readBack) === canonical(model);
|
|
1419
|
+
if (!same) bail("E_LAUNCH_CONFIG_INVALID", `${name} would not read back as written; nothing was written`);
|
|
1420
|
+
writeFileAtomic(file, next);
|
|
1421
|
+
let eff = null;
|
|
1422
|
+
try { eff = effective().find((c) => c.name === name) || null; } catch (e) { eff = { error: e.message }; }
|
|
1423
|
+
const receipt = { name, action: sub, level, file, before, after: Object.hasOwn(model, name) ? publicLaunchConfig(model[name]) : null, effective: eff };
|
|
1424
|
+
if (JSON_MODE) { jsonOk(receipt); return; }
|
|
1425
|
+
console.log(sub === "set" ? `Declared launch configuration ${name} at ${level} level (${shortPath(file)})` : `Removed launch configuration ${name} from ${level} level (${shortPath(file)})${eff ? `; ${eff.source} now provides it` : ""}`);
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1176
1428
|
|
|
1177
1429
|
/** Load the parsed capabilities model of a config file ({layers:{}, additive:{}}). */
|
|
1178
1430
|
function readCapabilitiesModel(file) {
|
|
@@ -3280,8 +3532,14 @@ function spawnCmd() {
|
|
|
3280
3532
|
const yolo = yoloFlag();
|
|
3281
3533
|
const backend = valueFlag("backend"), herdrSocket = valueFlag("herdr-socket");
|
|
3282
3534
|
if (backend !== undefined && !["tmux", "herdr"].includes(backend)) bail("E_BAD_ARGS", "--backend must be tmux or herdr");
|
|
3535
|
+
const requestedWork = valueFlag("work");
|
|
3536
|
+
const workDir = valueFlag("work-dir"), branch = valueFlag("branch"), repo = valueFlag("repo");
|
|
3537
|
+
const checkDirectoryOptions = (work) => {
|
|
3538
|
+
if (work === "directory" && (workDir !== undefined || branch !== undefined)) bail("E_BAD_ARGS", "--work directory owns only <home>/work; --work-dir and --branch are not allowed");
|
|
3539
|
+
};
|
|
3540
|
+
checkDirectoryOptions(requestedWork); // before a local soul could be upserted
|
|
3283
3541
|
const name = args[1];
|
|
3284
|
-
if (!name || name.startsWith("--")) bail("E_USAGE", "usage: oats spawn <agent> [--task <text>|--task-file <f>] [--purpose <slug>] [--relation child|sibling|parent|unrelated --relative-to <instance> [--relative-root <agents-root>]] [--parent <instance>] [--repo <r>] [--work worktree|checkout|attached|workspace] [--work-dir <owner-work>] [--runtime pi|claude|codex] [--backend tmux|herdr] [--herdr-socket <path>] [--yolo|--no-yolo] [--model <m>] [--branch <b>] [--instructions-file <f>|--def-file <f>] [--no-launch] [--json]");
|
|
3542
|
+
if (!name || name.startsWith("--")) bail("E_USAGE", "usage: oats spawn <agent> [--task <text>|--task-file <f>] [--purpose <slug>] [--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>] [--runtime pi|claude|codex] [--backend tmux|herdr] [--herdr-socket <path>] [--yolo|--no-yolo] [--model <m>] [--branch <b>] [--instructions-file <f>|--def-file <f>] [--no-launch] [--json]");
|
|
3285
3543
|
// Retired boundary flags (maintainer transport ruling): fail LOUDLY before
|
|
3286
3544
|
// ANY side effect — including root discovery and local-agent upsert (an
|
|
3287
3545
|
// --instructions-file spawn must not scaffold/overwrite a local soul before
|
|
@@ -3314,6 +3572,7 @@ function spawnCmd() {
|
|
|
3314
3572
|
note(`(cross-repo: soul "${name}" found at ${shortPath(root)} — instance homes there)`);
|
|
3315
3573
|
}
|
|
3316
3574
|
}
|
|
3575
|
+
checkDirectoryOptions(requestedWork || agent?.work);
|
|
3317
3576
|
// local agents: create/update from raw instructions or a single-file def
|
|
3318
3577
|
if (instrFile || defFile || !agent) {
|
|
3319
3578
|
if (!agent && !instrFile && !defFile) {
|
|
@@ -3399,8 +3658,12 @@ function spawnCmd() {
|
|
|
3399
3658
|
try {
|
|
3400
3659
|
r = spawnInstance(root, agent, {
|
|
3401
3660
|
purpose: flag("purpose"), task: taskText, taskFile: taskFileFlag, relation, relativeTo, relativeRoot,
|
|
3402
|
-
|
|
3403
|
-
|
|
3661
|
+
// Directory execution uses deployment configuration, not an ambient Git
|
|
3662
|
+
// checkout (especially when invoked via --dir from a source instance).
|
|
3663
|
+
repo: (requestedWork || agent.work) === "directory"
|
|
3664
|
+
? (repo ?? agent.repo) : repo || agent.repo || defaultRepo(workspaceOf(root)) || defaultRepo(process.cwd()),
|
|
3665
|
+
work: requestedWork, workDir, runtime: flag("runtime"), backend, herdrSocket, yolo, model: flag("model"), branch,
|
|
3666
|
+
launchConfig: valueFlag("launch-config"),
|
|
3404
3667
|
launch: !args.includes("--no-launch"),
|
|
3405
3668
|
});
|
|
3406
3669
|
} catch (e) {
|
|
@@ -3409,7 +3672,11 @@ function spawnCmd() {
|
|
|
3409
3672
|
// consumer the spawn mechanism broke, when the fixable fact is a poisoned
|
|
3410
3673
|
// document the message already names. The shared boundary renders it.
|
|
3411
3674
|
if (TYPED_CLI_FAILURES.has(e?.code)) throw e;
|
|
3412
|
-
|
|
3675
|
+
// A launch refusal (configuration, executable, environment reference,
|
|
3676
|
+
// model, runtime) is a fact about the selection, not a spawn-mechanism
|
|
3677
|
+
// failure: it keeps its own code so a GUI can act on it.
|
|
3678
|
+
if (typeof e?.code === "string" && /^E_LAUNCH_|^E_MODEL_UNKNOWN$|^E_UNSUPPORTED_RUNTIME$/.test(e.code)) { bail(e.code, e.message); throw e; }
|
|
3679
|
+
bail(["E_BAD_ARGS", "E_RELATIVE_AMBIGUOUS"].includes(e.code) ? e.code : "E_SPAWN_FAILED", e.message || e); throw e;
|
|
3413
3680
|
}
|
|
3414
3681
|
// The instance exists from here on: a failed wake save is reported beside
|
|
3415
3682
|
// the full receipt, never hidden, and never causes a second spawn.
|
|
@@ -3430,6 +3697,7 @@ function spawnCmd() {
|
|
|
3430
3697
|
spawnOrigin: r.spawnOrigin, attach: r.attach,
|
|
3431
3698
|
...(r.sessionTarget ? { sessionTarget: r.sessionTarget } : {}),
|
|
3432
3699
|
...(r.yolo !== undefined ? { yolo: r.yolo } : {}),
|
|
3700
|
+
launchConfig: r.launch?.launchConfig ?? null, launch: r.launch || null, // already redacted by the kernel
|
|
3433
3701
|
});
|
|
3434
3702
|
return;
|
|
3435
3703
|
}
|
|
@@ -3437,7 +3705,7 @@ function spawnCmd() {
|
|
|
3437
3705
|
console.log(` home: ${shortPath(r.home)}`);
|
|
3438
3706
|
if (wakeSchedule) console.log(` wake: schedule ${wakeSchedule.id} (${wakeSchedule.cron} ${wakeSchedule.tz}), next ${wakeSchedule.nextRun || "disabled"}`);
|
|
3439
3707
|
if (wakeScheduleError) console.error(` wake: NOT saved — ${wakeScheduleError.message} (the instance is created and launched; add the wake by hand with oats schedule add)`);
|
|
3440
|
-
if (!r.launched) console.log(` launch:
|
|
3708
|
+
if (!r.launched) console.log(` launch: oats session start --home ${shellQuote(r.home)}`);
|
|
3441
3709
|
for (const w of r.warnings || []) console.log(` WARNING: ${w}`);
|
|
3442
3710
|
console.log(` attach: ${r.attach}`);
|
|
3443
3711
|
}
|
|
@@ -3562,10 +3830,20 @@ async function sessionCmd() {
|
|
|
3562
3830
|
return;
|
|
3563
3831
|
}
|
|
3564
3832
|
if (args[1] === "inspect") result = inspectInstanceSession(home);
|
|
3565
|
-
else if (args[1] === "start") {
|
|
3833
|
+
else if (args[1] === "start" || args[1] === "restart") {
|
|
3834
|
+
const bad = (msg) => { throw Object.assign(new Error(msg), { code: "E_BAD_ARGS" }); };
|
|
3566
3835
|
const model = flag("model");
|
|
3567
|
-
if (model === true)
|
|
3568
|
-
|
|
3836
|
+
if (model === true) bad("--model needs a model id; omit it to keep the recorded model");
|
|
3837
|
+
const launchConfig = flag("launch-config");
|
|
3838
|
+
if (launchConfig === true) bad("--launch-config needs a configuration name, or none");
|
|
3839
|
+
const runtime = flag("runtime");
|
|
3840
|
+
if (runtime === true || (runtime !== undefined && !LAUNCH_RUNTIMES.includes(runtime))) bad(`--runtime must be one of ${LAUNCH_RUNTIMES.join(", ")}`);
|
|
3841
|
+
const opts = { model: model || undefined, launchConfig, runtime, yolo: yoloFlag(), env: process.env };
|
|
3842
|
+
if (args[1] === "restart") {
|
|
3843
|
+
const grace = flag("stop-grace");
|
|
3844
|
+
if (grace !== undefined) { if (grace === true || !/^\d+$/.test(String(grace)) || Number(grace) < 1 || Number(grace) > 300) bad("--stop-grace needs a number of seconds (1..300) to wait for the harness after SIGTERM"); opts.stopGraceMs = Number(grace) * 1000; }
|
|
3845
|
+
result = restartInstanceSession(home, opts);
|
|
3846
|
+
} else result = startInstanceSession(home, opts);
|
|
3569
3847
|
} else if (args[1] === "input") {
|
|
3570
3848
|
const file = flag("text-file");
|
|
3571
3849
|
if (file === true) throw Object.assign(new Error("--text-file needs a path"), { code: "E_BAD_ARGS" });
|
|
@@ -3583,7 +3861,7 @@ async function sessionCmd() {
|
|
|
3583
3861
|
const file = flag("file");
|
|
3584
3862
|
if (!file || file === true) throw Object.assign(new Error("session upload needs --file <local path>"), { code: "E_BAD_ARGS" });
|
|
3585
3863
|
result = uploadAttachment({ file, home: home === true ? undefined : home });
|
|
3586
|
-
} else throw Object.assign(new Error("usage: oats session inspect|input|attach|start|receive|upload --home /absolute/home [--text-file path] [--model id] [--name file] [--file path] [--json]"), { code: "E_BAD_ARGS" });
|
|
3864
|
+
} else throw Object.assign(new Error("usage: oats session inspect|input|attach|start|restart|receive|upload --home /absolute/home [--text-file path] [--model id] [--launch-config name|none] [--runtime pi|claude|codex] [--yolo|--no-yolo] [--stop-grace seconds] [--name file] [--file path] [--json]"), { code: "E_BAD_ARGS" });
|
|
3587
3865
|
if (JSON_MODE) jsonOk(result); else console.log(JSON.stringify(result, null, 2));
|
|
3588
3866
|
} catch (e) { cmdFail(e.code || "E_SESSION_FAILED", e.message); }
|
|
3589
3867
|
}
|
|
@@ -3595,7 +3873,7 @@ async function paneCmd() {
|
|
|
3595
3873
|
function createCmd() {
|
|
3596
3874
|
const yolo = yoloFlag();
|
|
3597
3875
|
const name = args[1];
|
|
3598
|
-
if (!name || name.startsWith("--")) die("usage: oats create <name> [--local] [--description <d>] [--type <agent-type>] [--repo <r>] [--work worktree|checkout|attached|workspace] [--runtime pi|claude|codex] [--model <m>] [--yolo|--no-yolo] [--instructions-file <f>]");
|
|
3876
|
+
if (!name || name.startsWith("--")) die("usage: oats create <name> [--local] [--description <d>] [--type <agent-type>] [--repo <r>] [--work worktree|checkout|attached|workspace|directory] [--runtime pi|claude|codex] [--model <m>] [--yolo|--no-yolo] [--instructions-file <f>]");
|
|
3599
3877
|
const local = args.includes("--local");
|
|
3600
3878
|
const startDir = dirFlag();
|
|
3601
3879
|
// `create` BOOTSTRAPS a deployment: with no agents/ or local-agents/ yet,
|
|
@@ -3605,14 +3883,16 @@ function createCmd() {
|
|
|
3605
3883
|
// `oats init` (a raw stack trace from ensureRoot). Local and committed souls
|
|
3606
3884
|
// anchor the same way; writeSoul creates the directories.
|
|
3607
3885
|
let root = findRoot(startDir);
|
|
3608
|
-
|
|
3886
|
+
// A configured package-only scope may resolve its future agents root before
|
|
3887
|
+
// that directory exists. Preserve create's bootstrap receipt/message.
|
|
3888
|
+
let bootstrapped = !!root && !existsSync(root) && !existsSync(join(dirname(root), "local-agents"));
|
|
3609
3889
|
if (!root) {
|
|
3610
3890
|
root = join(defaultRepo(startDir) || resolve(startDir), "agents");
|
|
3611
3891
|
bootstrapped = true;
|
|
3612
3892
|
}
|
|
3613
3893
|
const instrFile = flag("instructions-file");
|
|
3614
3894
|
const r = coreCreateAgent(root, {
|
|
3615
|
-
name, local, description: flag("description"), type: flag("type"), repo: flag("repo") || defaultRepo(process.cwd()),
|
|
3895
|
+
name, local, description: flag("description"), type: flag("type"), repo: flag("repo") || (flag("work") === "directory" ? undefined : defaultRepo(process.cwd())),
|
|
3616
3896
|
work: flag("work"), runtime: flag("runtime"), model: flag("model"), yolo,
|
|
3617
3897
|
instructions: instrFile ? readFileSync(instrFile, "utf8") : undefined,
|
|
3618
3898
|
});
|
|
@@ -3878,7 +4158,7 @@ function versionCmd() {
|
|
|
3878
4158
|
// on it (an older CLI without the surface must fail closed with a
|
|
3879
4159
|
// reason, not an argument error). `features`: kernel abilities a peer
|
|
3880
4160
|
// must see before relying on them (retire-home: retire --home).
|
|
3881
|
-
console.log(JSON.stringify({ schemaVersion: 1, name: "@awebai/oats", version: OATS_VERSION, desktopApi: 1, runtimes: ["pi", "claude", "codex"], sessionBackends: ["tmux", "herdr"], launchOptions: ["yolo"], remote: ["spawn", "retire", "status", "session", "session-start", "roster", "harvest", "schedule", "session-upload", "operations"], features: ["retire-home", "session-start", "schedule", "session-upload", "operations"], scheduleApi: SCHEDULE_API, operationsApi: 1 }));
|
|
4161
|
+
console.log(JSON.stringify({ schemaVersion: 1, name: "@awebai/oats", version: OATS_VERSION, desktopApi: 1, runtimes: ["pi", "claude", "codex"], sessionBackends: ["tmux", "herdr"], launchOptions: ["yolo"], remote: ["spawn", "retire", "status", "session", "session-start", "session-restart", "launch-config", "roster", "harvest", "schedule", "session-upload", "operations"], features: ["retire-home", "session-start", "session-restart", "launch-config", "schedule", "session-upload", "operations"], scheduleApi: SCHEDULE_API, operationsApi: 1 }));
|
|
3882
4162
|
return;
|
|
3883
4163
|
}
|
|
3884
4164
|
console.log(`@awebai/oats ${OATS_VERSION} (desktop API v1)`);
|
|
@@ -4009,15 +4289,44 @@ function serverCmd() {
|
|
|
4009
4289
|
/** `oats <spawn|retire|status> --server <id> ...`: run the command on the
|
|
4010
4290
|
* registered server's installed oats, same arguments, same envelope. The
|
|
4011
4291
|
* local side only routes and keeps the route snapshot per remote instance. */
|
|
4012
|
-
function serverRouteCmd() {
|
|
4292
|
+
async function serverRouteCmd() {
|
|
4013
4293
|
const bail = (code, msg) => (JSON_MODE ? jsonFail(code, msg) : die(msg));
|
|
4014
4294
|
const id = flag("server");
|
|
4015
4295
|
if (id === true || !id) bail("E_BAD_ARGS", "--server needs a registered server id (oats server list)");
|
|
4016
4296
|
// The operations contract addresses an exact member context on the host,
|
|
4017
4297
|
// so its explicit --dir travels; every other routed command takes its
|
|
4018
4298
|
// scope from the registration.
|
|
4019
|
-
const explicitScopeOk = ["inspect", "operation", "use", "soul"].includes(cmd);
|
|
4299
|
+
const explicitScopeOk = ["inspect", "operation", "use", "soul", "launch-config"].includes(cmd);
|
|
4020
4300
|
if (!explicitScopeOk && (flag("dir") !== undefined || args.some((a) => a.startsWith("--dir=")))) bail("E_BAD_ARGS", "--dir cannot be combined with --server: the remote workspace comes from the server registration");
|
|
4301
|
+
if (cmd === "launch-config") {
|
|
4302
|
+
const action = args[1];
|
|
4303
|
+
const value = (name) => { const v = flag(name); if (v === true) bail("E_BAD_ARGS", `--${name} needs a value`); return v; };
|
|
4304
|
+
if (!["list", "set", "remove", "preview"].includes(action)) bail("E_BAD_ARGS", "launch-config --server supports list, set, remove and preview");
|
|
4305
|
+
const options = { action, name: args[2], context: value("dir"), home: value("home"), instance: value("instance"), soul: value("soul"), agentsRoot: value("agents-root") };
|
|
4306
|
+
if (action === "preview") Object.assign(options, { launchConfig: value("launch-config"), runtime: value("runtime"), model: value("model"), yolo: yoloFlag() });
|
|
4307
|
+
if (action === "set") {
|
|
4308
|
+
const file = value("file");
|
|
4309
|
+
if (!file) bail("E_BAD_ARGS", "launch-config set needs --file <local JSON file> (or - for stdin)");
|
|
4310
|
+
let raw;
|
|
4311
|
+
try {
|
|
4312
|
+
if (file === "-") {
|
|
4313
|
+
if (process.stdin.isTTY) bail("E_BAD_ARGS", "--file - reads the definition from stdin");
|
|
4314
|
+
raw = await readStreamBounded(process.stdin, INSPECT_TEXT_CAP);
|
|
4315
|
+
} else raw = readFileSync(file);
|
|
4316
|
+
} catch (e) { bail("E_BAD_ARGS", `cannot read launch configuration file (${e.code || "read failed"})`); }
|
|
4317
|
+
if (raw.length > INSPECT_TEXT_CAP) bail("E_BAD_ARGS", "launch configuration file exceeds the input limit");
|
|
4318
|
+
try { options.definition = JSON.parse(raw.toString("utf8")); }
|
|
4319
|
+
catch { bail("E_BAD_ARGS", "launch configuration file is not valid JSON"); }
|
|
4320
|
+
options.keepEnv = args.includes("--keep-env");
|
|
4321
|
+
}
|
|
4322
|
+
let out;
|
|
4323
|
+
try { out = launchConfigRemote(id, options); } catch (e) { bail(e.code || "E_SSH", e.message); }
|
|
4324
|
+
if (out.stderr?.trim()) process.stderr.write(out.stderr.endsWith("\n") ? out.stderr : out.stderr + "\n");
|
|
4325
|
+
if (JSON_MODE) { console.log(JSON.stringify(out.envelope, null, 2)); if (!out.envelope.ok) process.exit(1); return; }
|
|
4326
|
+
if (!out.envelope.ok) die(`${id}: ${out.envelope.error?.message || "launch configuration request failed"} (${out.envelope.error?.code || "E_REMOTE"})`);
|
|
4327
|
+
console.log(JSON.stringify(out.envelope.result, null, 2));
|
|
4328
|
+
return;
|
|
4329
|
+
}
|
|
4021
4330
|
// Interactive viewer: `oats session attach --server <id> --instance <name>`
|
|
4022
4331
|
// (or --home </abs/remote/home>) runs the execution host's own attach
|
|
4023
4332
|
// through an ssh PTY with this terminal's stdio; nothing is captured.
|
|
@@ -4077,11 +4386,12 @@ function serverRouteCmd() {
|
|
|
4077
4386
|
console.log(`${r.instance || r.home} on ${id}: ${r.present ? `present, ${r.state || "unknown"}` : "not present"}${r.backend ? ` (${r.backend})` : ""}`);
|
|
4078
4387
|
return;
|
|
4079
4388
|
}
|
|
4080
|
-
if (args[1] === "start") {
|
|
4081
|
-
const
|
|
4082
|
-
|
|
4389
|
+
if (args[1] === "start" || args[1] === "restart") {
|
|
4390
|
+
const value = (name) => { const v = flag(name); if (v === true) bail("E_BAD_ARGS", `--${name} needs a value`); return v; };
|
|
4391
|
+
const choices = { ...addr, model: value("model"), launchConfig: value("launch-config"), runtime: value("runtime"), yolo: yoloFlag() };
|
|
4392
|
+
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");
|
|
4083
4393
|
let out;
|
|
4084
|
-
try { out =
|
|
4394
|
+
try { out = (args[1] === "restart" ? restartRemote : startRemote)(id, choices); } catch (e) { bail(e.code || "E_SSH", e.message); }
|
|
4085
4395
|
if (out.stderr?.trim()) process.stderr.write(out.stderr.endsWith("\n") ? out.stderr : out.stderr + "\n");
|
|
4086
4396
|
if (JSON_MODE) { console.log(JSON.stringify(out.envelope, null, 2)); if (!out.envelope.ok) process.exit(1); return; }
|
|
4087
4397
|
if (!out.envelope.ok) die(`${id}: ${out.envelope.error?.message || "start failed"} (${out.envelope.error?.code || "E_REMOTE"})`);
|
|
@@ -4101,7 +4411,7 @@ function serverRouteCmd() {
|
|
|
4101
4411
|
console.log(`Uploaded ${r.name} (${r.bytes} bytes) to ${r.instance || r.home} on ${id}: ${r.path}`);
|
|
4102
4412
|
return;
|
|
4103
4413
|
}
|
|
4104
|
-
if (args[1] !== "attach") bail("E_USAGE", "--server routes `session inspect`, `session start`, `session upload` and `session attach`; input runs on the execution host (the wake broker calls it there)");
|
|
4414
|
+
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)");
|
|
4105
4415
|
let route;
|
|
4106
4416
|
try { route = attachArgv(id, addr, { skipVersionCheck: args.includes("--print") }); }
|
|
4107
4417
|
catch (e) { bail(e.code || "E_BAD_ARGS", e.message); }
|
|
@@ -4217,14 +4527,15 @@ try {
|
|
|
4217
4527
|
// and exits 0 BEFORE any dispatch: a fresh operator inspects --help before
|
|
4218
4528
|
// using a command, and `install --help` once ran the bare restore while
|
|
4219
4529
|
// `okf harvest --help` spawned a harvester (BeadHub, 2026-09-05).
|
|
4220
|
-
const KERNEL_COMMANDS = new Set(["capture", "config", "create", "doctor", "inspect", "operation", "soul", "experimental", "init", "inject", "install", "list", "migrate", "pane", "recall", "remove", "retire", "root", "schedule", "server", "session", "setup", "spawn", "status", "trust", "type", "update", "use", "version"]);
|
|
4530
|
+
const KERNEL_COMMANDS = new Set(["capture", "config", "create", "doctor", "inspect", "operation", "soul", "launch-config", "experimental", "init", "inject", "install", "list", "migrate", "pane", "recall", "remove", "retire", "root", "schedule", "server", "session", "setup", "spawn", "status", "trust", "type", "update", "use", "version"]);
|
|
4221
4531
|
const wantsHelp = args.slice(1).some((a) => a === "--help" || a === "-h");
|
|
4222
4532
|
if (cmd && KERNEL_COMMANDS.has(cmd) && wantsHelp) { if (JSON_MODE) { jsonOk({ command: cmd, usage: usageLinesFor(cmd) }); process.exit(0); } usageFor(cmd); process.exit(0); }
|
|
4223
|
-
if (flag("server") !== undefined && ["spawn", "retire", "status", "session", "okf", "schedule", "inspect", "operation", "use", "soul"].includes(cmd)) serverRouteCmd();
|
|
4533
|
+
if (flag("server") !== undefined && ["spawn", "retire", "status", "session", "okf", "schedule", "inspect", "operation", "use", "soul", "launch-config"].includes(cmd)) await serverRouteCmd();
|
|
4224
4534
|
else if (cmd === "server") serverCmd();
|
|
4225
4535
|
else if (cmd === "inspect") inspectCmd();
|
|
4226
4536
|
else if (cmd === "operation") operationCmd();
|
|
4227
4537
|
else if (cmd === "soul") await soulCmd();
|
|
4538
|
+
else if (cmd === "launch-config") await launchConfigCmd();
|
|
4228
4539
|
else if (cmd === "doctor") {
|
|
4229
4540
|
const doctorDir = args[1] && !args[1].startsWith("--") ? args[1] : undefined;
|
|
4230
4541
|
args.includes("--json") ? doctorJson(doctorDir) : doctor(doctorDir);
|
|
@@ -4347,6 +4658,12 @@ Usage:
|
|
|
4347
4658
|
oats session receive --home <abs> --name store attachment bytes read from stdin (the routed
|
|
4348
4659
|
<file> [--json] upload's remote half)
|
|
4349
4660
|
oats session start --home <absolute-home> start a STOPPED instance again in its existing home
|
|
4661
|
+
[--model m] [--launch-config n|none] (recorded recipe as is; a selection re-resolves it
|
|
4662
|
+
[--runtime r] [--yolo|--no-yolo] against the scope; a named configuration is a unit)
|
|
4663
|
+
oats session restart --home <abs-home> stop the running harness (SIGTERM, bounded wait,
|
|
4664
|
+
[same flags] [--stop-grace <s>] never escalated) and start it again in place under
|
|
4665
|
+
the same lock; a stop that is not observed is
|
|
4666
|
+
reported and nothing is launched
|
|
4350
4667
|
[--model <m>] [--json] (same identity, worktree, notes and launch env; no
|
|
4351
4668
|
spawn hooks); --model replaces the recorded model
|
|
4352
4669
|
for this and later starts; a live harness is refused
|
|
@@ -4356,11 +4673,13 @@ Usage:
|
|
|
4356
4673
|
[--relation child|sibling|parent|unrelated] --relation + --relative-to anchor the
|
|
4357
4674
|
[--relative-to <instance>] new instance to an existing one; --parent X
|
|
4358
4675
|
[--relative-root <agents-root>] disambiguates same-named team anchors
|
|
4359
|
-
[--work worktree|checkout|attached|workspace] = sugar for --relative-to X --relation
|
|
4676
|
+
[--work worktree|checkout|attached|workspace|directory] = sugar for --relative-to X --relation
|
|
4360
4677
|
[--work-dir <owner-work>] [--runtime pi|claude|codex] [--backend tmux|herdr] [--herdr-socket <path>] [--yolo|--no-yolo] [--model <m>] [--branch <b>] child (default: unrelated, top-level)
|
|
4361
4678
|
[--instructions-file <f>|--def-file <f>] [--no-launch] [--json]
|
|
4362
4679
|
with team: declared, unknown local souls
|
|
4363
4680
|
resolve across the team scope's repos
|
|
4681
|
+
directory: owned home/work, config context may
|
|
4682
|
+
be non-Git; rejects --work-dir and --branch
|
|
4364
4683
|
oats retire <instance> [--force] retire an instance (window, hooks,
|
|
4365
4684
|
[--self] [--delete-branch] worktree, home); --self = retire the
|
|
4366
4685
|
[--keep-dir] [--json] CALLING instance: the window dies, then
|
|
@@ -4384,6 +4703,21 @@ Usage:
|
|
|
4384
4703
|
[--yolo | --no-yolo] [--backend b] --instructions-file); packaged souls are refused;
|
|
4385
4704
|
[--description d | --no-description] the receipt carries before/after and sha256s
|
|
4386
4705
|
[--instructions-file <path>] [--json]
|
|
4706
|
+
oats launch-config list [--dir <scope> named launch configurations effective at a scope,
|
|
4707
|
+
| --home <abs> | --soul <name>] a home's recorded context or a soul's own scope:
|
|
4708
|
+
[--agents-root <abs>] [--json] runtime, executable, args, env (values redacted,
|
|
4709
|
+
references shown), model, yolo; the closest
|
|
4710
|
+
declaring scope provides the whole entry
|
|
4711
|
+
oats launch-config set <name> --file <j> declare or replace one at this scope from a JSON
|
|
4712
|
+
[--keep-env] [--dir <scope>] [--json] file (only the launch-configs block is rewritten;
|
|
4713
|
+
--keep-env copies the effective definition's env)
|
|
4714
|
+
oats launch-config remove <name> remove this scope's declaration; an ancestor's,
|
|
4715
|
+
[--dir <scope>] [--json] if any, becomes effective again
|
|
4716
|
+
oats launch-config preview what a start would run: resolved runtime, model,
|
|
4717
|
+
(--home <abs> | --soul <name>) yolo, executable, argv, environment (redacted),
|
|
4718
|
+
[--launch-config <name>|none] command and preflight; read-only, nothing
|
|
4719
|
+
[--runtime r] [--model m] started; a named configuration is a unit, so
|
|
4720
|
+
[--yolo | --no-yolo] --json a disagreeing --runtime is refused
|
|
4387
4721
|
oats doctor [dir] [--soul <name>] [--json] resolved targets, trust, requirements;
|
|
4388
4722
|
--soul shows final composed AGENTS.md
|
|
4389
4723
|
oats update [--check] [--yes] check npm for a newer kernel+pi bridge and
|