@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.
Files changed (45) hide show
  1. package/README.md +7 -2
  2. package/bin/oats.mjs +365 -31
  3. package/docs/configuration.md +65 -0
  4. package/docs/design/2026-09-07-mobile-agent-management-proposal.md +228 -0
  5. package/docs/design/2026-09-08-expert-assisted-deployment-proposal.md +558 -0
  6. package/docs/design/2026-09-13-knowledge-and-memory-direction.md +744 -0
  7. package/docs/design/2026-09-13-knowledge-implementation.md +127 -0
  8. package/docs/design/2026-09-13-knowledge-location-contract.md +340 -0
  9. package/docs/design/launch-configurations.md +164 -0
  10. package/docs/design/package-runtime-api.md +177 -3
  11. package/docs/desktop-cli-api.md +68 -2
  12. package/docs/desktop-instance-start.md +39 -3
  13. package/docs/execution-targets.md +16 -0
  14. package/docs/knowledge-capability-authoring.md +98 -0
  15. package/docs/knowledge-reference/acceptance.md +108 -0
  16. package/docs/knowledge-reference/adoption.md +61 -0
  17. package/docs/knowledge-reference/harvester.md +107 -0
  18. package/docs/knowledge-reference/model.md +84 -0
  19. package/docs/knowledge-reference/package-craft.md +126 -0
  20. package/docs/knowledge-reference/provider-mapping.md +77 -0
  21. package/docs/knowledge-reference/reader-capture.md +87 -0
  22. package/docs/knowledge-theory.md +20 -6
  23. package/docs/layers.md +8 -7
  24. package/docs/oats-config.schema.json +33 -2
  25. package/docs/release-notes/v0.22.18.md +101 -0
  26. package/docs/release-notes/v0.22.19.md +115 -0
  27. package/docs/release-notes/v0.23.0.md +93 -0
  28. package/docs/souls-and-instances.md +18 -1
  29. package/injects/work-directory.md +18 -0
  30. package/lib/core.mjs +1109 -187
  31. package/lib/schedule.mjs +12 -2
  32. package/lib/servers.mjs +89 -4
  33. package/package.json +2 -2
  34. package/packages/record/README.md +19 -0
  35. package/packages/record/bin/capture.mjs +144 -53
  36. package/packages/record/bin/recall.mjs +17 -11
  37. package/packages/record/bin/record-native-start.mjs +11 -0
  38. package/packages/record/lib/capture-cc.mjs +82 -27
  39. package/packages/record/lib/capture-lock.mjs +81 -5
  40. package/packages/record/lib/formats.mjs +108 -21
  41. package/packages/record/lib/native-history.mjs +87 -0
  42. package/packages/record/lib/session-roots.mjs +90 -0
  43. package/packages/record/lib/session-snapshot.mjs +61 -0
  44. package/packages/record/lib/sessions-for-home.mjs +88 -56
  45. 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 ["worktree", "checkout", "attached", "workspace"]) {
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
- repo: flag("repo") || agent.repo || defaultRepo(workspaceOf(root)) || defaultRepo(process.cwd()),
3403
- work: flag("work"), workDir: flag("work-dir"), runtime: flag("runtime"), backend, herdrSocket, yolo, model: flag("model"), branch: flag("branch"),
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
- bail(e.code === "E_RELATIVE_AMBIGUOUS" ? "E_RELATIVE_AMBIGUOUS" : "E_SPAWN_FAILED", e.message || e); throw e;
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: (cd ${shortPath(r.home)} && ${r.command})`);
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) throw Object.assign(new Error("--model needs a model id; omit it to keep the recorded model"), { code: "E_BAD_ARGS" });
3568
- result = startInstanceSession(home, { model: model || undefined });
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
- let bootstrapped = false;
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 model = flag("model");
4082
- if (model === true) bail("E_BAD_ARGS", "--model needs a model id; omit it to keep the recorded model");
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 = startRemote(id, { ...addr, model: model || undefined }); } catch (e) { bail(e.code || "E_SSH", e.message); }
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