@awebai/oats 0.22.16 → 0.22.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/bin/oats.mjs +341 -21
- package/capabilities/oats-okf/bin/oats-okf.mjs +24 -9
- package/capabilities/oats-okf/oats.json +1 -1
- package/docs/configuration.md +65 -0
- package/docs/design/2026-09-07-mobile-agent-management-proposal.md +228 -0
- package/docs/design/launch-configurations.md +164 -0
- package/docs/desktop-cli-api.md +67 -1
- package/docs/desktop-instance-start.md +39 -3
- package/docs/oats-config.schema.json +28 -0
- package/docs/release-notes/v0.22.17.md +25 -0
- package/docs/release-notes/v0.22.18.md +101 -0
- package/docs/release-notes/v0.22.19.md +115 -0
- package/docs/souls-and-instances.md +1 -0
- package/lib/core.mjs +836 -137
- package/lib/servers.mjs +89 -4
- package/package-catalog.json +1 -1
- package/package.json +1 -1
- package/packages/record/bin/capture.mjs +49 -6
- package/packages/record/lib/capture-lock.mjs +68 -5
package/README.md
CHANGED
|
@@ -307,6 +307,7 @@ forms. Do not hand-edit the lock or installed stores.
|
|
|
307
307
|
- [Migration from OAS](docs/migration-from-oas.md)
|
|
308
308
|
- [Release notes](docs/release-notes/)
|
|
309
309
|
- [Architecture proposal, 2026-09-03](docs/2026-09-03-architecture-proposal.md): components, contracts, and what may be replaced (proposal, not shipped behavior)
|
|
310
|
+
- [iPhone agent management proposal](docs/design/2026-09-07-mobile-agent-management-proposal.md): private server access through Tailscale, mobile UX, and delivery phases (proposal, not shipped behavior)
|
|
310
311
|
|
|
311
312
|
## Contributing
|
|
312
313
|
|
package/bin/oats.mjs
CHANGED
|
@@ -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 = "";
|
|
@@ -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) {
|
|
@@ -3401,6 +3653,7 @@ function spawnCmd() {
|
|
|
3401
3653
|
purpose: flag("purpose"), task: taskText, taskFile: taskFileFlag, relation, relativeTo, relativeRoot,
|
|
3402
3654
|
repo: flag("repo") || agent.repo || defaultRepo(workspaceOf(root)) || defaultRepo(process.cwd()),
|
|
3403
3655
|
work: flag("work"), workDir: flag("work-dir"), runtime: flag("runtime"), backend, herdrSocket, yolo, model: flag("model"), branch: flag("branch"),
|
|
3656
|
+
launchConfig: valueFlag("launch-config"),
|
|
3404
3657
|
launch: !args.includes("--no-launch"),
|
|
3405
3658
|
});
|
|
3406
3659
|
} catch (e) {
|
|
@@ -3409,6 +3662,10 @@ function spawnCmd() {
|
|
|
3409
3662
|
// consumer the spawn mechanism broke, when the fixable fact is a poisoned
|
|
3410
3663
|
// document the message already names. The shared boundary renders it.
|
|
3411
3664
|
if (TYPED_CLI_FAILURES.has(e?.code)) throw e;
|
|
3665
|
+
// A launch refusal (configuration, executable, environment reference,
|
|
3666
|
+
// model, runtime) is a fact about the selection, not a spawn-mechanism
|
|
3667
|
+
// failure: it keeps its own code so a GUI can act on it.
|
|
3668
|
+
if (typeof e?.code === "string" && /^E_LAUNCH_|^E_MODEL_UNKNOWN$|^E_UNSUPPORTED_RUNTIME$/.test(e.code)) { bail(e.code, e.message); throw e; }
|
|
3412
3669
|
bail(e.code === "E_RELATIVE_AMBIGUOUS" ? "E_RELATIVE_AMBIGUOUS" : "E_SPAWN_FAILED", e.message || e); throw e;
|
|
3413
3670
|
}
|
|
3414
3671
|
// The instance exists from here on: a failed wake save is reported beside
|
|
@@ -3430,6 +3687,7 @@ function spawnCmd() {
|
|
|
3430
3687
|
spawnOrigin: r.spawnOrigin, attach: r.attach,
|
|
3431
3688
|
...(r.sessionTarget ? { sessionTarget: r.sessionTarget } : {}),
|
|
3432
3689
|
...(r.yolo !== undefined ? { yolo: r.yolo } : {}),
|
|
3690
|
+
launchConfig: r.launch?.launchConfig ?? null, launch: r.launch || null, // already redacted by the kernel
|
|
3433
3691
|
});
|
|
3434
3692
|
return;
|
|
3435
3693
|
}
|
|
@@ -3437,7 +3695,7 @@ function spawnCmd() {
|
|
|
3437
3695
|
console.log(` home: ${shortPath(r.home)}`);
|
|
3438
3696
|
if (wakeSchedule) console.log(` wake: schedule ${wakeSchedule.id} (${wakeSchedule.cron} ${wakeSchedule.tz}), next ${wakeSchedule.nextRun || "disabled"}`);
|
|
3439
3697
|
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:
|
|
3698
|
+
if (!r.launched) console.log(` launch: oats session start --home ${shellQuote(r.home)}`);
|
|
3441
3699
|
for (const w of r.warnings || []) console.log(` WARNING: ${w}`);
|
|
3442
3700
|
console.log(` attach: ${r.attach}`);
|
|
3443
3701
|
}
|
|
@@ -3562,10 +3820,20 @@ async function sessionCmd() {
|
|
|
3562
3820
|
return;
|
|
3563
3821
|
}
|
|
3564
3822
|
if (args[1] === "inspect") result = inspectInstanceSession(home);
|
|
3565
|
-
else if (args[1] === "start") {
|
|
3823
|
+
else if (args[1] === "start" || args[1] === "restart") {
|
|
3824
|
+
const bad = (msg) => { throw Object.assign(new Error(msg), { code: "E_BAD_ARGS" }); };
|
|
3566
3825
|
const model = flag("model");
|
|
3567
|
-
if (model === true)
|
|
3568
|
-
|
|
3826
|
+
if (model === true) bad("--model needs a model id; omit it to keep the recorded model");
|
|
3827
|
+
const launchConfig = flag("launch-config");
|
|
3828
|
+
if (launchConfig === true) bad("--launch-config needs a configuration name, or none");
|
|
3829
|
+
const runtime = flag("runtime");
|
|
3830
|
+
if (runtime === true || (runtime !== undefined && !LAUNCH_RUNTIMES.includes(runtime))) bad(`--runtime must be one of ${LAUNCH_RUNTIMES.join(", ")}`);
|
|
3831
|
+
const opts = { model: model || undefined, launchConfig, runtime, yolo: yoloFlag(), env: process.env };
|
|
3832
|
+
if (args[1] === "restart") {
|
|
3833
|
+
const grace = flag("stop-grace");
|
|
3834
|
+
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; }
|
|
3835
|
+
result = restartInstanceSession(home, opts);
|
|
3836
|
+
} else result = startInstanceSession(home, opts);
|
|
3569
3837
|
} else if (args[1] === "input") {
|
|
3570
3838
|
const file = flag("text-file");
|
|
3571
3839
|
if (file === true) throw Object.assign(new Error("--text-file needs a path"), { code: "E_BAD_ARGS" });
|
|
@@ -3583,7 +3851,7 @@ async function sessionCmd() {
|
|
|
3583
3851
|
const file = flag("file");
|
|
3584
3852
|
if (!file || file === true) throw Object.assign(new Error("session upload needs --file <local path>"), { code: "E_BAD_ARGS" });
|
|
3585
3853
|
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" });
|
|
3854
|
+
} 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
3855
|
if (JSON_MODE) jsonOk(result); else console.log(JSON.stringify(result, null, 2));
|
|
3588
3856
|
} catch (e) { cmdFail(e.code || "E_SESSION_FAILED", e.message); }
|
|
3589
3857
|
}
|
|
@@ -3878,7 +4146,7 @@ function versionCmd() {
|
|
|
3878
4146
|
// on it (an older CLI without the surface must fail closed with a
|
|
3879
4147
|
// reason, not an argument error). `features`: kernel abilities a peer
|
|
3880
4148
|
// 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 }));
|
|
4149
|
+
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
4150
|
return;
|
|
3883
4151
|
}
|
|
3884
4152
|
console.log(`@awebai/oats ${OATS_VERSION} (desktop API v1)`);
|
|
@@ -4009,15 +4277,44 @@ function serverCmd() {
|
|
|
4009
4277
|
/** `oats <spawn|retire|status> --server <id> ...`: run the command on the
|
|
4010
4278
|
* registered server's installed oats, same arguments, same envelope. The
|
|
4011
4279
|
* local side only routes and keeps the route snapshot per remote instance. */
|
|
4012
|
-
function serverRouteCmd() {
|
|
4280
|
+
async function serverRouteCmd() {
|
|
4013
4281
|
const bail = (code, msg) => (JSON_MODE ? jsonFail(code, msg) : die(msg));
|
|
4014
4282
|
const id = flag("server");
|
|
4015
4283
|
if (id === true || !id) bail("E_BAD_ARGS", "--server needs a registered server id (oats server list)");
|
|
4016
4284
|
// The operations contract addresses an exact member context on the host,
|
|
4017
4285
|
// so its explicit --dir travels; every other routed command takes its
|
|
4018
4286
|
// scope from the registration.
|
|
4019
|
-
const explicitScopeOk = ["inspect", "operation", "use", "soul"].includes(cmd);
|
|
4287
|
+
const explicitScopeOk = ["inspect", "operation", "use", "soul", "launch-config"].includes(cmd);
|
|
4020
4288
|
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");
|
|
4289
|
+
if (cmd === "launch-config") {
|
|
4290
|
+
const action = args[1];
|
|
4291
|
+
const value = (name) => { const v = flag(name); if (v === true) bail("E_BAD_ARGS", `--${name} needs a value`); return v; };
|
|
4292
|
+
if (!["list", "set", "remove", "preview"].includes(action)) bail("E_BAD_ARGS", "launch-config --server supports list, set, remove and preview");
|
|
4293
|
+
const options = { action, name: args[2], context: value("dir"), home: value("home"), instance: value("instance"), soul: value("soul"), agentsRoot: value("agents-root") };
|
|
4294
|
+
if (action === "preview") Object.assign(options, { launchConfig: value("launch-config"), runtime: value("runtime"), model: value("model"), yolo: yoloFlag() });
|
|
4295
|
+
if (action === "set") {
|
|
4296
|
+
const file = value("file");
|
|
4297
|
+
if (!file) bail("E_BAD_ARGS", "launch-config set needs --file <local JSON file> (or - for stdin)");
|
|
4298
|
+
let raw;
|
|
4299
|
+
try {
|
|
4300
|
+
if (file === "-") {
|
|
4301
|
+
if (process.stdin.isTTY) bail("E_BAD_ARGS", "--file - reads the definition from stdin");
|
|
4302
|
+
raw = await readStreamBounded(process.stdin, INSPECT_TEXT_CAP);
|
|
4303
|
+
} else raw = readFileSync(file);
|
|
4304
|
+
} catch (e) { bail("E_BAD_ARGS", `cannot read launch configuration file (${e.code || "read failed"})`); }
|
|
4305
|
+
if (raw.length > INSPECT_TEXT_CAP) bail("E_BAD_ARGS", "launch configuration file exceeds the input limit");
|
|
4306
|
+
try { options.definition = JSON.parse(raw.toString("utf8")); }
|
|
4307
|
+
catch { bail("E_BAD_ARGS", "launch configuration file is not valid JSON"); }
|
|
4308
|
+
options.keepEnv = args.includes("--keep-env");
|
|
4309
|
+
}
|
|
4310
|
+
let out;
|
|
4311
|
+
try { out = launchConfigRemote(id, options); } catch (e) { bail(e.code || "E_SSH", e.message); }
|
|
4312
|
+
if (out.stderr?.trim()) process.stderr.write(out.stderr.endsWith("\n") ? out.stderr : out.stderr + "\n");
|
|
4313
|
+
if (JSON_MODE) { console.log(JSON.stringify(out.envelope, null, 2)); if (!out.envelope.ok) process.exit(1); return; }
|
|
4314
|
+
if (!out.envelope.ok) die(`${id}: ${out.envelope.error?.message || "launch configuration request failed"} (${out.envelope.error?.code || "E_REMOTE"})`);
|
|
4315
|
+
console.log(JSON.stringify(out.envelope.result, null, 2));
|
|
4316
|
+
return;
|
|
4317
|
+
}
|
|
4021
4318
|
// Interactive viewer: `oats session attach --server <id> --instance <name>`
|
|
4022
4319
|
// (or --home </abs/remote/home>) runs the execution host's own attach
|
|
4023
4320
|
// through an ssh PTY with this terminal's stdio; nothing is captured.
|
|
@@ -4077,11 +4374,12 @@ function serverRouteCmd() {
|
|
|
4077
4374
|
console.log(`${r.instance || r.home} on ${id}: ${r.present ? `present, ${r.state || "unknown"}` : "not present"}${r.backend ? ` (${r.backend})` : ""}`);
|
|
4078
4375
|
return;
|
|
4079
4376
|
}
|
|
4080
|
-
if (args[1] === "start") {
|
|
4081
|
-
const
|
|
4082
|
-
|
|
4377
|
+
if (args[1] === "start" || args[1] === "restart") {
|
|
4378
|
+
const value = (name) => { const v = flag(name); if (v === true) bail("E_BAD_ARGS", `--${name} needs a value`); return v; };
|
|
4379
|
+
const choices = { ...addr, model: value("model"), launchConfig: value("launch-config"), runtime: value("runtime"), yolo: yoloFlag() };
|
|
4380
|
+
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
4381
|
let out;
|
|
4084
|
-
try { out =
|
|
4382
|
+
try { out = (args[1] === "restart" ? restartRemote : startRemote)(id, choices); } catch (e) { bail(e.code || "E_SSH", e.message); }
|
|
4085
4383
|
if (out.stderr?.trim()) process.stderr.write(out.stderr.endsWith("\n") ? out.stderr : out.stderr + "\n");
|
|
4086
4384
|
if (JSON_MODE) { console.log(JSON.stringify(out.envelope, null, 2)); if (!out.envelope.ok) process.exit(1); return; }
|
|
4087
4385
|
if (!out.envelope.ok) die(`${id}: ${out.envelope.error?.message || "start failed"} (${out.envelope.error?.code || "E_REMOTE"})`);
|
|
@@ -4101,7 +4399,7 @@ function serverRouteCmd() {
|
|
|
4101
4399
|
console.log(`Uploaded ${r.name} (${r.bytes} bytes) to ${r.instance || r.home} on ${id}: ${r.path}`);
|
|
4102
4400
|
return;
|
|
4103
4401
|
}
|
|
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)");
|
|
4402
|
+
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
4403
|
let route;
|
|
4106
4404
|
try { route = attachArgv(id, addr, { skipVersionCheck: args.includes("--print") }); }
|
|
4107
4405
|
catch (e) { bail(e.code || "E_BAD_ARGS", e.message); }
|
|
@@ -4217,14 +4515,15 @@ try {
|
|
|
4217
4515
|
// and exits 0 BEFORE any dispatch: a fresh operator inspects --help before
|
|
4218
4516
|
// using a command, and `install --help` once ran the bare restore while
|
|
4219
4517
|
// `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"]);
|
|
4518
|
+
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
4519
|
const wantsHelp = args.slice(1).some((a) => a === "--help" || a === "-h");
|
|
4222
4520
|
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();
|
|
4521
|
+
if (flag("server") !== undefined && ["spawn", "retire", "status", "session", "okf", "schedule", "inspect", "operation", "use", "soul", "launch-config"].includes(cmd)) await serverRouteCmd();
|
|
4224
4522
|
else if (cmd === "server") serverCmd();
|
|
4225
4523
|
else if (cmd === "inspect") inspectCmd();
|
|
4226
4524
|
else if (cmd === "operation") operationCmd();
|
|
4227
4525
|
else if (cmd === "soul") await soulCmd();
|
|
4526
|
+
else if (cmd === "launch-config") await launchConfigCmd();
|
|
4228
4527
|
else if (cmd === "doctor") {
|
|
4229
4528
|
const doctorDir = args[1] && !args[1].startsWith("--") ? args[1] : undefined;
|
|
4230
4529
|
args.includes("--json") ? doctorJson(doctorDir) : doctor(doctorDir);
|
|
@@ -4347,6 +4646,12 @@ Usage:
|
|
|
4347
4646
|
oats session receive --home <abs> --name store attachment bytes read from stdin (the routed
|
|
4348
4647
|
<file> [--json] upload's remote half)
|
|
4349
4648
|
oats session start --home <absolute-home> start a STOPPED instance again in its existing home
|
|
4649
|
+
[--model m] [--launch-config n|none] (recorded recipe as is; a selection re-resolves it
|
|
4650
|
+
[--runtime r] [--yolo|--no-yolo] against the scope; a named configuration is a unit)
|
|
4651
|
+
oats session restart --home <abs-home> stop the running harness (SIGTERM, bounded wait,
|
|
4652
|
+
[same flags] [--stop-grace <s>] never escalated) and start it again in place under
|
|
4653
|
+
the same lock; a stop that is not observed is
|
|
4654
|
+
reported and nothing is launched
|
|
4350
4655
|
[--model <m>] [--json] (same identity, worktree, notes and launch env; no
|
|
4351
4656
|
spawn hooks); --model replaces the recorded model
|
|
4352
4657
|
for this and later starts; a live harness is refused
|
|
@@ -4384,6 +4689,21 @@ Usage:
|
|
|
4384
4689
|
[--yolo | --no-yolo] [--backend b] --instructions-file); packaged souls are refused;
|
|
4385
4690
|
[--description d | --no-description] the receipt carries before/after and sha256s
|
|
4386
4691
|
[--instructions-file <path>] [--json]
|
|
4692
|
+
oats launch-config list [--dir <scope> named launch configurations effective at a scope,
|
|
4693
|
+
| --home <abs> | --soul <name>] a home's recorded context or a soul's own scope:
|
|
4694
|
+
[--agents-root <abs>] [--json] runtime, executable, args, env (values redacted,
|
|
4695
|
+
references shown), model, yolo; the closest
|
|
4696
|
+
declaring scope provides the whole entry
|
|
4697
|
+
oats launch-config set <name> --file <j> declare or replace one at this scope from a JSON
|
|
4698
|
+
[--keep-env] [--dir <scope>] [--json] file (only the launch-configs block is rewritten;
|
|
4699
|
+
--keep-env copies the effective definition's env)
|
|
4700
|
+
oats launch-config remove <name> remove this scope's declaration; an ancestor's,
|
|
4701
|
+
[--dir <scope>] [--json] if any, becomes effective again
|
|
4702
|
+
oats launch-config preview what a start would run: resolved runtime, model,
|
|
4703
|
+
(--home <abs> | --soul <name>) yolo, executable, argv, environment (redacted),
|
|
4704
|
+
[--launch-config <name>|none] command and preflight; read-only, nothing
|
|
4705
|
+
[--runtime r] [--model m] started; a named configuration is a unit, so
|
|
4706
|
+
[--yolo | --no-yolo] --json a disagreeing --runtime is refused
|
|
4387
4707
|
oats doctor [dir] [--soul <name>] [--json] resolved targets, trust, requirements;
|
|
4388
4708
|
--soul shows final composed AGENTS.md
|
|
4389
4709
|
oats update [--check] [--yes] check npm for a newer kernel+pi bridge and
|
|
@@ -23,29 +23,44 @@
|
|
|
23
23
|
* OATS_TASK (spawn), OATS_REPO/OATS_BRANCH/OATS_WORK (spawn), OATS_META (retire).
|
|
24
24
|
* Output: JSON { meta, brief, warning } on stdout. Failures warn, never block.
|
|
25
25
|
*/
|
|
26
|
-
import { existsSync, mkdirSync, mkdtempSync, writeFileSync, readFileSync, readdirSync, realpathSync, rmSync } from "node:fs";
|
|
26
|
+
import { existsSync, mkdirSync, mkdtempSync, writeFileSync, readFileSync, readdirSync, realpathSync, rmSync, writeSync } from "node:fs";
|
|
27
27
|
import { join, isAbsolute, dirname } from "node:path";
|
|
28
28
|
import { tmpdir } from "node:os";
|
|
29
29
|
import { execFile, spawnSync } from "node:child_process";
|
|
30
30
|
import { reclaimHarvestBranch } from "../lib/harvest-branch.mjs";
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
// Every answer leaves through here: written whole, then exit. stdout is a
|
|
33
|
+
// pipe when the kernel's operation runner, a Desktop or a test reads it, and
|
|
34
|
+
// on macOS Node writes to a pipe asynchronously: process.exit right after
|
|
35
|
+
// process.stdout.write drops whatever has not left Node's buffer (64 KiB,
|
|
36
|
+
// so a view of one long STATE.md arrived cut). fs.writeSync on fd 1 blocks
|
|
37
|
+
// until the OS has the bytes; EAGAIN (a non-blocking fd whose reader is
|
|
38
|
+
// slower than us) is retried after a short wait. The exit stays synchronous,
|
|
39
|
+
// which the call sites below rely on to end their flow.
|
|
40
|
+
const emit = (text, code) => {
|
|
41
|
+
const buf = Buffer.from(text, "utf8");
|
|
42
|
+
for (let off = 0; off < buf.length;) {
|
|
43
|
+
try { off += writeSync(1, buf, off, buf.length - off); }
|
|
44
|
+
catch (e) { if (e.code !== "EAGAIN") throw e; Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5); }
|
|
45
|
+
}
|
|
46
|
+
process.exit(code);
|
|
47
|
+
};
|
|
48
|
+
const out = (o) => emit(JSON.stringify(o) + "\n", 0);
|
|
33
49
|
const warn = (m) => out({ warning: `oats-okf: ${String(m).slice(0, 300)}` });
|
|
34
50
|
// A reported failure must not exit 0: callers and hooks read the status.
|
|
35
|
-
const warnFail = (m) =>
|
|
51
|
+
const warnFail = (m) => emit(JSON.stringify({ warning: `oats-okf: ${String(m).slice(0, 300)}` }) + "\n", 1);
|
|
36
52
|
|
|
37
53
|
// Desktop CLI API v1: `oats okf harvest --json` emits EXACTLY ONE envelope
|
|
38
54
|
// object on stdout — {schemaVersion:1,ok,result|error} — and a nonzero exit
|
|
39
55
|
// on failure. Ordinary (non---json) output keeps the hook JSON shape above.
|
|
40
56
|
const JSON_MODE = process.argv.includes("--json");
|
|
41
|
-
const jsonOk = (result) =>
|
|
42
|
-
const jsonFail = (code, message) =>
|
|
57
|
+
const jsonOk = (result) => emit(JSON.stringify({ schemaVersion: 1, ok: true, result }) + "\n", 0);
|
|
58
|
+
const jsonFail = (code, message) => emit(JSON.stringify({ schemaVersion: 1, ok: false, error: { code, message: String(message).slice(0, 300) } }) + "\n", 1);
|
|
43
59
|
|
|
44
60
|
// --help/-h never runs a command here either (the kernel answers it from the
|
|
45
61
|
// manifest since 0.22.6; this keeps an older kernel from spawning a harvester).
|
|
46
62
|
if (process.argv.slice(2).some((a) => a === "--help" || a === "-h")) {
|
|
47
|
-
|
|
48
|
-
process.exit(0);
|
|
63
|
+
emit("oats okf harvest [--json] [--from-record] [--force] promote this instance's pending notes (and record windows) into its soul by spawning a memory-harvest worker; --help never runs it\noats okf inspect [--json] answer this instance's working knowledge (STATE.md, log.md, pending notes) as labeled documents\n", 0);
|
|
49
64
|
}
|
|
50
65
|
const event = process.env.OATS_EVENT || process.argv[2];
|
|
51
66
|
const instance = process.env.OATS_INSTANCE;
|
|
@@ -493,9 +508,9 @@ _(the single next action — keep this current; a fresh session on any model res
|
|
|
493
508
|
const notes = existsSync(notesDir) ? readdirSync(notesDir).filter((f) => f.endsWith(".md")).sort() : [];
|
|
494
509
|
for (const f of notes) { const d = doc(`Pending note: ${f}`, join(notesDir, f)); if (d) documents.push(d); }
|
|
495
510
|
const summary = documents.length ? `${documents.length} document${documents.length === 1 ? "" : "s"}: ${existsSync(join(home, "STATE.md")) ? "state" : "no state"}, ${existsSync(join(home, "log.md")) ? "log" : "no log"}, ${notes.length} pending note${notes.length === 1 ? "" : "s"}` : "no working memory in this home yet (STATE.md, log.md and notes/ are written by the instance)";
|
|
496
|
-
|
|
511
|
+
jsonOk({ summary, documents });
|
|
497
512
|
} catch (e) {
|
|
498
|
-
|
|
513
|
+
jsonFail("E_INSPECT_FAILED", e.message || e);
|
|
499
514
|
}
|
|
500
515
|
} else if (event === "retire") {
|
|
501
516
|
// Retirement is intentionally a no-op for knowledge (for now): promotion happens
|