@awebai/oats 0.22.17 → 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/lib/core.mjs CHANGED
@@ -31,12 +31,13 @@ import {
31
31
  chmodSync, closeSync, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, rmdirSync, statSync, symlinkSync, writeFileSync,
32
32
  } from "node:fs";
33
33
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
34
+ import { accessSync, constants as fsConstants } from "node:fs";
34
35
  import { homedir, tmpdir } from "node:os";
35
36
  import { createHash, randomUUID } from "node:crypto";
36
37
  import { fileURLToPath } from "node:url";
37
38
  import { attachSessionTarget } from "./session-viewer.mjs";
38
39
  import { inspectSessionTarget, inputSessionTarget } from "./session-input.mjs";
39
- import { ensureHerdr, allocateHerdr, launchHerdr, inspectHerdr, stopHerdr, validHerdrTarget, herdrSnapshot } from "./herdr.mjs";
40
+ import { ensureHerdr, allocateHerdr, launchHerdr, inspectHerdr, stopHerdr, validHerdrTarget, herdrSnapshot, herdrCommand } from "./herdr.mjs";
40
41
 
41
42
  export const RESERVED = new Set(["bin", "local-agents", "tmp-agents"]);
42
43
  /** The work modes spawn accepts — also the enum a quarantine cleanup descriptor
@@ -210,30 +211,106 @@ export function parseYamlFlat(text) {
210
211
  /** Small dependency-free YAML subset used by oats-config.yaml.
211
212
  * Supports nested maps, namespaced/quoted keys, booleans, numbers, and inline arrays/maps. */
212
213
  function yamlScalar(raw) {
213
- const val = raw.trim().replace(/\s+#.*$/, "").trim();
214
+ const trimmed = raw.trim();
215
+ // A double-quoted scalar is read with JSON's escape rules (what the CLI
216
+ // writes for values with spaces, quotes or metacharacters); a single-quoted
217
+ // one with YAML's doubled-quote rule. A trailing comment never cuts a
218
+ // quoted value. Anything the escape rules refuse falls back to the raw text
219
+ // between the quotes, as before.
220
+ if (trimmed.startsWith('"')) {
221
+ let i = 1, esc = false;
222
+ for (; i < trimmed.length; i++) { const c = trimmed[i]; if (esc) esc = false; else if (c === "\\") esc = true; else if (c === '"') break; }
223
+ if (i < trimmed.length) { const q = trimmed.slice(0, i + 1); try { return JSON.parse(q); } catch { return q.slice(1, -1); } }
224
+ }
225
+ if (trimmed.startsWith("'")) {
226
+ let i = 1, out = "";
227
+ for (; i < trimmed.length; i++) { const c = trimmed[i]; if (c === "'") { if (trimmed[i + 1] === "'") { out += "'"; i++; continue; } break; } out += c; }
228
+ if (i < trimmed.length) return out;
229
+ }
230
+ // Inline collections are split quote- and nesting-aware, so an element
231
+ // may carry commas, "#" or nothing at all; a trailing comment after the
232
+ // closing bracket is dropped, one inside quotes is kept.
233
+ if (trimmed.startsWith("[") || trimmed.startsWith("{")) {
234
+ const close = inlineCollectionEnd(trimmed);
235
+ if (close > 0) {
236
+ const inner = trimmed.slice(1, close);
237
+ const parts = splitInline(inner);
238
+ if (trimmed[0] === "[") return parts.map((v) => yamlScalar(v));
239
+ const out = {};
240
+ for (const part of parts) {
241
+ const i = inlineKeyEnd(part);
242
+ if (i < 0) continue;
243
+ const key = yamlKey(part.slice(0, i).trim().replace(/^["']|["']$/g, ""));
244
+ out[key] = yamlScalar(part.slice(i + 1));
245
+ }
246
+ return out;
247
+ }
248
+ }
249
+ const val = trimmed.replace(/\s+#.*$/, "").trim();
214
250
  if (/^(true|false)$/i.test(val)) return val.toLowerCase() === "true";
215
251
  if (/^(null|~)$/i.test(val)) return null;
216
252
  if (/^-?\d+(\.\d+)?$/.test(val)) return Number(val);
217
- if (val.startsWith("[") && val.endsWith("]")) {
218
- return val.slice(1, -1).split(",").map((v) => yamlScalar(v)).filter((v) => v !== "");
219
- }
220
- if (val.startsWith("{") && val.endsWith("}")) {
221
- const out = {};
222
- for (const part of val.slice(1, -1).split(",")) {
223
- const i = part.indexOf(":");
224
- if (i < 0) continue;
225
- const key = yamlKey(part.slice(0, i).trim().replace(/^["']|["']$/g, ""));
226
- out[key] = yamlScalar(part.slice(i + 1));
227
- }
228
- return out;
229
- }
230
253
  return val.replace(/^["']|["']$/g, "");
231
254
  }
255
+ /** Index of the bracket closing the inline collection that opens `s`, or -1. */
256
+ function inlineCollectionEnd(s) {
257
+ let depth = 0, quote = null;
258
+ for (let i = 0; i < s.length; i++) {
259
+ const c = s[i];
260
+ if (quote) { if (c === "\\" && quote === '"') { i++; continue; } if (c === quote) { if (quote === "'" && s[i + 1] === "'") { i++; continue; } quote = null; } continue; }
261
+ if (c === '"' || c === "'") { quote = c; continue; }
262
+ if (c === "[" || c === "{") depth++;
263
+ else if (c === "]" || c === "}") { depth--; if (depth === 0) return i; }
264
+ }
265
+ return -1;
266
+ }
267
+ /** Top-level comma split of an inline collection body (quotes and nesting respected). */
268
+ function splitInline(inner) {
269
+ const parts = []; let depth = 0, quote = null, cur = "", any = false;
270
+ for (let i = 0; i < inner.length; i++) {
271
+ const c = inner[i];
272
+ if (quote) { cur += c; if (c === "\\" && quote === '"') { cur += inner[i + 1] ?? ""; i++; continue; } if (c === quote) { if (quote === "'" && inner[i + 1] === "'") { cur += "'"; i++; continue; } quote = null; } continue; }
273
+ if (c === '"' || c === "'") { quote = c; cur += c; any = true; continue; }
274
+ if (c === "[" || c === "{") depth++; else if (c === "]" || c === "}") depth--;
275
+ if (c === "," && depth === 0) { parts.push(cur); cur = ""; any = true; continue; }
276
+ if (!/\s/.test(c)) any = true;
277
+ cur += c;
278
+ }
279
+ if (any || cur.trim()) parts.push(cur);
280
+ return parts.filter((p, i) => !(i === parts.length - 1 && !p.trim() && !quoteOnly(p)));
281
+ }
282
+ const quoteOnly = (p) => /^\s*(""|'')\s*$/.test(p);
283
+ /** The first ":" of an inline map entry outside quotes. */
284
+ function inlineKeyEnd(part) {
285
+ let quote = null;
286
+ for (let i = 0; i < part.length; i++) {
287
+ const c = part[i];
288
+ if (quote) { if (c === quote) quote = null; continue; }
289
+ if (c === '"' || c === "'") { quote = c; continue; }
290
+ if (c === ":") return i;
291
+ }
292
+ return -1;
293
+ }
232
294
  export function parseYamlNested(text) {
233
295
  const root = {};
234
296
  const stack = [{ indent: -1, node: root }];
235
297
  for (const raw of text.split("\n")) {
236
298
  if (!raw.trim() || raw.trim().startsWith("#")) continue;
299
+ // A block sequence item ("- value") belongs to the key that opened the
300
+ // current node; the first item turns that node into an array. Items are
301
+ // scalars only (a "- key: value" item is read as the scalar text).
302
+ const seq = raw.match(/^(\s*)-(?:\s+(.*?))?\s*$/);
303
+ if (seq) {
304
+ const indent = seq[1].length;
305
+ while (stack.length > 1 && indent <= stack[stack.length - 1].indent) stack.pop();
306
+ const top = stack[stack.length - 1];
307
+ if (!Array.isArray(top.node)) {
308
+ if (!top.parent || Object.keys(top.node).length) continue; // not a list position: ignored, as before
309
+ top.node = []; top.parent[top.key] = top.node;
310
+ }
311
+ if (seq[2] !== undefined && seq[2] !== "") top.node.push(yamlScalar(seq[2]));
312
+ continue;
313
+ }
237
314
  const m = raw.match(/^(\s*)((?:["'][^"']+["'])|(?:[^:#][^:]*?)):\s*(.*?)\s*$/);
238
315
  if (!m) continue;
239
316
  const [, ws, rawKey, rawVal] = m;
@@ -241,10 +318,11 @@ export function parseYamlNested(text) {
241
318
  const indent = ws.length;
242
319
  while (stack.length > 1 && indent <= stack[stack.length - 1].indent) stack.pop();
243
320
  const parent = stack[stack.length - 1].node;
321
+ if (Array.isArray(parent)) continue; // a key line inside a sequence is not part of this subset
244
322
  if (rawVal.replace(/\s+#.*$/, "").trim() === "" || rawVal.trim().startsWith("#")) {
245
323
  const child = {};
246
324
  parent[key] = child;
247
- stack.push({ indent, node: child });
325
+ stack.push({ indent, node: child, parent, key });
248
326
  } else parent[key] = yamlScalar(rawVal);
249
327
  }
250
328
  return root;
@@ -429,7 +507,71 @@ export const RETIRED_CAPABILITIES = {
429
507
  export function retiredCapabilityReason(id) {
430
508
  return Object.hasOwn(RETIRED_CAPABILITIES, id) ? RETIRED_CAPABILITIES[id] : undefined;
431
509
  }
432
- const CONFIG_KEYS = new Set(["name", "team", "agent-types", "capabilities", "skill-overrides", "agents-md-injection", "oats", "work-modes", "templates", "yolo"]);
510
+ const CONFIG_KEYS = new Set(["name", "team", "agent-types", "capabilities", "skill-overrides", "agents-md-injection", "oats", "work-modes", "templates", "yolo", "launch-configs"]);
511
+
512
+ // ---------- launch configurations ----------
513
+ // A named way to start a harness, independent of any soul: the runtime, an
514
+ // executable (a wrapper, another binary), literal argv, environment (literal
515
+ // values, or references resolved on the execution host at start time), a
516
+ // model and yolo. Declared per scope under `launch-configs:`; the closest
517
+ // scope declaring a NAME provides the whole entry (no merging between
518
+ // scopes). Selected at spawn or session start/restart by name.
519
+ export const LAUNCH_RUNTIMES = ["pi", "claude", "codex"];
520
+ export const LAUNCH_CONFIG_KEYS = new Set(["runtime", "executable", "args", "env", "model", "yolo"]);
521
+ const LAUNCH_CONFIG_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
522
+ const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
523
+ /** Environment the kernel sets for every launch (identity, home, roots) and
524
+ * its reference aliases: a configuration may not name them. */
525
+ export const RESERVED_LAUNCH_ENV = new Set(["OATS_INSTANCE", "OATS_INSTANCE_HOME", "OATS_HOME", "OATS_AGENT", "OATS_SOUL", "OATS_ROOT", "OATS_CONTEXT", "OATS_WORKSPACE", "OATS_EVENT", "OATS_SETTINGS", "OATS_CLI_BIN", "PI_AGENT_INSTANCE", "PI_AGENT_HOME", "PI_AGENTS_ROOT"]);
526
+ export const LAUNCH_REF_PREFIX = "OATS_LAUNCH_REF_";
527
+ const reservedLaunchEnv = (n) => RESERVED_LAUNCH_ENV.has(n) || n.startsWith(LAUNCH_REF_PREFIX);
528
+ export function validateLaunchConfig(name, entry, where) {
529
+ const bad = (why) => { throw oatsError("E_LAUNCH_CONFIG_INVALID", `launch configuration ${JSON.stringify(name)}${where ? ` in ${where}` : ""} ${why}`); };
530
+ if (typeof name !== "string" || !LAUNCH_CONFIG_NAME.test(name)) bad("has an invalid name (letters, digits, dot, underscore, dash; up to 64 characters)");
531
+ if (name === "none") bad("cannot be named none: that word selects no configuration");
532
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) bad("must be a map");
533
+ for (const key of Object.keys(entry)) if (!LAUNCH_CONFIG_KEYS.has(key)) bad(`has an unsupported key ${JSON.stringify(key)} (runtime, executable, args, env, model, yolo)`);
534
+ if (!LAUNCH_RUNTIMES.includes(entry.runtime)) bad(`needs runtime: one of ${LAUNCH_RUNTIMES.join(", ")}`);
535
+ const text = (v, what) => { if (typeof v !== "string" || !v.trim() || v.includes("\0")) bad(`${what} must be non-empty text`); };
536
+ if (entry.executable !== undefined) text(entry.executable, "executable");
537
+ if (entry.args !== undefined) {
538
+ if (!Array.isArray(entry.args)) bad("args must be a list of strings");
539
+ for (const a of entry.args) if (typeof a !== "string" || a.includes("\0")) bad("args must be a list of strings");
540
+ }
541
+ if (entry.env !== undefined) {
542
+ if (!entry.env || typeof entry.env !== "object" || Array.isArray(entry.env)) bad("env must be a map of NAME to a string or {fromEnv: NAME}");
543
+ for (const [n, v] of Object.entries(entry.env)) {
544
+ if (!ENV_NAME.test(n)) bad(`env name ${JSON.stringify(n)} is not a valid environment variable name`);
545
+ if (reservedLaunchEnv(n)) bad(`env ${n} is set by the kernel for every launch and cannot be overridden`);
546
+ if (typeof v === "string") { if (v.includes("\0")) bad(`env ${n} must be text`); continue; }
547
+ if (!v || typeof v !== "object" || Array.isArray(v) || Object.keys(v).length !== 1 || typeof v.fromEnv !== "string" || !ENV_NAME.test(v.fromEnv)) bad(`env ${n} must be a string or {fromEnv: NAME}`);
548
+ if (v && typeof v === "object" && v.fromEnv && v.fromEnv.startsWith(LAUNCH_REF_PREFIX)) bad(`env ${n} may not reference a ${LAUNCH_REF_PREFIX}* alias`);
549
+ }
550
+ }
551
+ if (entry.model !== undefined) text(entry.model, "model");
552
+ if (entry.yolo !== undefined && typeof entry.yolo !== "boolean") bad("yolo must be true or false");
553
+ return entry;
554
+ }
555
+ function validateLaunchConfigs(map, file) {
556
+ if (map === undefined) return;
557
+ if (!map || typeof map !== "object" || Array.isArray(map)) throw oatsError("E_LAUNCH_CONFIG_INVALID", `launch-configs in ${file} must be a map of name to configuration`);
558
+ for (const [name, entry] of Object.entries(map)) validateLaunchConfig(name, entry, file);
559
+ }
560
+ /** The effective launch configurations of a config chain (closest first):
561
+ * the closest scope declaring a name provides the WHOLE entry; farther
562
+ * declarations of the same name are recorded as shadowed. */
563
+ export function launchConfigsOf(chain) {
564
+ // Null-prototype: a configuration may legitimately be named constructor or
565
+ // toString, and membership must never be an inherited property.
566
+ const out = Object.create(null);
567
+ for (const c of chain) {
568
+ for (const [name, entry] of Object.entries(c["launch-configs"] || {})) {
569
+ if (Object.hasOwn(out, name)) { out[name].shadows.push(c._level); continue; }
570
+ out[name] = { name, runtime: entry.runtime, ...(entry.executable !== undefined ? { executable: entry.executable } : {}), args: [...(entry.args || [])], env: { ...(entry.env || {}) }, ...(entry.model !== undefined ? { model: entry.model } : {}), ...(entry.yolo !== undefined ? { yolo: entry.yolo } : {}), source: c._level, shadows: [] };
571
+ }
572
+ }
573
+ return out;
574
+ }
433
575
  /** Renamed-key tables are read with OWN-property semantics only: a config key
434
576
  * spelled `constructor`/`toString` inherits a value from `Object.prototype`,
435
577
  * and the plain `TABLE[key]` lookup then reported it as the migration hint —
@@ -471,6 +613,7 @@ function loadLevelConfig(dir) {
471
613
  * config source material and must pass the same shape checks). */
472
614
  export function validateConfigShape(cfg, file) {
473
615
  if (cfg.yolo !== undefined && typeof cfg.yolo !== "boolean") throw new Error(`yolo in ${file} must be true or false`);
616
+ validateLaunchConfigs(cfg["launch-configs"], file);
474
617
  for (const key of Object.keys(cfg)) {
475
618
  if (Object.hasOwn(RENAMED_CONFIG_KEYS, key)) throw new Error(`unsupported oats-config key "${key}" in ${file} — ${RENAMED_CONFIG_KEYS[key]}`);
476
619
  if (!CONFIG_KEYS.has(key)) throw new Error(`unsupported oats-config key in ${file}: ${key}`);
@@ -588,7 +731,7 @@ function manifestRequiredHooks(manifest) {
588
731
  }
589
732
  return out;
590
733
  }
591
- const APPROVED_HOOKS = new Set(["soul-scaffold", "spawn", "retire"]);
734
+ const APPROVED_HOOKS = new Set(["soul-scaffold", "spawn", "retire", "launch"]);
592
735
 
593
736
  /** The declared type (agent family) of a soul, read from its soul.yaml via the agents root. */
594
737
  export function soulTypeOf(contextDir, soulName) {
@@ -748,6 +891,7 @@ export function resolveOatsConfig(contextDir, soulName) {
748
891
  const out = { layers: {}, provenance: {}, layerDisabled: {}, injects: [], capabilities: [], name: chain[0]?.name, chain };
749
892
  const yoloCfg = chain.find((c) => c.yolo !== undefined);
750
893
  if (yoloCfg) out.yolo = yoloCfg.yolo;
894
+ out.launchConfigs = launchConfigsOf(chain);
751
895
  // Closest team: declaration wins; the declaring scope is the deployment/team boundary.
752
896
  const teamCfg = chain.find((c) => c.team);
753
897
  if (teamCfg) out.team = { ...teamCfg.team, scope: teamCfg._level };
@@ -4184,9 +4328,11 @@ export const RUNTIME_PACKAGE_MANAGERS = {
4184
4328
  * roots. `--no-approve` keeps a spawn-time probe from trusting
4185
4329
  * project-local files. Falls back to reading settings when pi cannot be
4186
4330
  * run, which yields presence without a verified directory. */
4187
- list: (env = process.env) => {
4331
+ list: (env = process.env, opts = {}) => {
4188
4332
  try {
4189
- const out = execFileSync("pi", ["list", "--no-approve"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], env, timeout: 30000 });
4333
+ // The SELECTED executable answers (a wrapper or another binary), with
4334
+ // pi's own controlled list subcommand only: never a launch argument.
4335
+ const out = execFileSync(opts.bin || "pi", ["list", "--no-approve"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], env, timeout: 30000 });
4190
4336
  const rows = [];
4191
4337
  // pi dims the path with chalk; strip any escapes before matching.
4192
4338
  const lines = out.replace(/\u001b\[[0-9;]*m/g, "").split("\n");
@@ -4476,7 +4622,7 @@ function validateHookEnvironment(capabilityID, value, owners, declarations) {
4476
4622
  }
4477
4623
 
4478
4624
  export function runLifecycleHooks(event, { home, instance, agentName, soulDir, contextDir, workspaceDir, rootDir, resolved, priorMeta = {}, extraEnv = {} }) {
4479
- const results = { meta: {}, briefs: [], warnings: [], order: [], launch: {}, env: {}, failures: [] };
4625
+ const results = { meta: {}, briefs: [], warnings: [], order: [], launch: {}, env: {}, failures: [], contributions: [] };
4480
4626
  const envOwners = new Map();
4481
4627
  const envDeclarations = new Map((resolved.capabilities || []).map((cap) => [cap.id, { names: new Set(cap.environment || []), namespaces: [...(cap.environmentNamespaces || [])] }]));
4482
4628
  const caps = [...(resolved.capabilities || [])];
@@ -4516,8 +4662,16 @@ export function runLifecycleHooks(event, { home, instance, agentName, soulDir, c
4516
4662
  if (o.brief) results.briefs.push(`- ${o.brief}`);
4517
4663
  if (o.warning) results.warnings.push(o.warning);
4518
4664
  if (o.launch && typeof o.launch === "object") for (const [rt, args] of Object.entries(o.launch)) results.launch[rt] = `${results.launch[rt] ? `${results.launch[rt]} ` : ""}${args}`;
4665
+ // Provenance of what this capability contributed to the launch: which
4666
+ // runtimes it answered launch args for, and which env names, under
4667
+ // which settings and trust. A later start or runtime switch reads this.
4668
+ // A launch hook's run is recorded even when its answer is empty: an
4669
+ // empty answer replaces what the provider contributed before.
4670
+ if (event === "launch" || (o.launch && typeof o.launch === "object" && Object.keys(o.launch).length) || (o.env && typeof o.env === "object" && Object.keys(o.env).length)) {
4671
+ results.contributions.push({ capability: cap.id, layer: cap.layer || null, level: cap.level || null, settings: { ...(cap.settings || {}) }, trust: { trusted: !!cap.trust?.trusted, integrity: cap.trust?.integrity || null }, launch: o.launch && typeof o.launch === "object" ? { ...o.launch } : {}, env: o.env && typeof o.env === "object" ? Object.keys(o.env).sort() : [] });
4672
+ }
4519
4673
  if (o.env !== undefined) {
4520
- if (event !== "spawn") throw new HookEnvironmentContractError(`${cap.id} hook env is supported only for spawn, not ${event}`);
4674
+ if (event !== "spawn" && event !== "launch") throw new HookEnvironmentContractError(`${cap.id} hook env is supported only for spawn and launch, not ${event}`);
4521
4675
  Object.assign(results.env, validateHookEnvironment(cap.id, o.env, envOwners, envDeclarations));
4522
4676
  }
4523
4677
  } catch (e) {
@@ -5085,13 +5239,16 @@ export const RELATIONS = ["child", "sibling", "parent", "unrelated"];
5085
5239
  * Absence still fails the spawn: "aweb on pi requires the aweb pi package" is a
5086
5240
  * promise the instance's INSTRUCTIONS rely on, so starting without it would
5087
5241
  * leave the agent believing it can be woken by mail when it cannot. */
5088
- function verifyRuntimePackages(runtime, resolved, contextDir) {
5242
+ function verifyRuntimePackages(runtime, resolved, contextDir, { bin, env } = {}) {
5243
+ const probeEnv = env || process.env;
5089
5244
  const found = [];
5090
5245
  const problems = [];
5091
- // The session launches with the CONTEXT-SELECTED executable (oats-claude-config
5092
- // may name `claude-personal`), so probing the literal `claude` would inspect a
5093
- // different account's plugins than the instance will actually use.
5094
- const probeOpts = runtime === "claude" ? { bin: resolveClaudeBinary(contextDir), context: contextDir } : { context: contextDir };
5246
+ // The session launches with the SELECTED executable (a configuration's,
5247
+ // or the context-selected one: oats-claude-config may name
5248
+ // `claude-personal`), so probing another binary would inspect a different
5249
+ // account's packages than the instance will actually use. The probe is the
5250
+ // manager's controlled list subcommand; no launch argument is added to it.
5251
+ const probeOpts = { context: contextDir, ...(bin ? { bin } : runtime === "claude" ? { bin: resolveClaudeBinary(contextDir) } : {}) };
5095
5252
  for (const cap of resolved.capabilities || []) {
5096
5253
  for (const raw of cap.manifest?.requires || []) {
5097
5254
  if (!raw || typeof raw !== "object" || raw.runtime !== runtime) continue;
@@ -5103,7 +5260,7 @@ function verifyRuntimePackages(runtime, resolved, contextDir) {
5103
5260
  const spec = raw.package;
5104
5261
  if (!safeRuntimePackageSpec(spec, runtime)) { problems.push(`${cap.id}: ${runtime} package spec is not a plain source token (${JSON.stringify(spec)})`); continue; }
5105
5262
  if (raw.marketplace !== undefined && !safeRuntimeSourceRef(raw.marketplace)) { problems.push(`${cap.id}: marketplace is not a plain source reference (${JSON.stringify(raw.marketplace)})`); continue; }
5106
- const status = runtimePackageStatus(runtime, spec, process.env, probeOpts);
5263
+ const status = runtimePackageStatus(runtime, spec, probeEnv, probeOpts);
5107
5264
  const mgr = RUNTIME_PACKAGE_MANAGERS[runtime];
5108
5265
  const stepList = mgr?.steps ? mgr.steps(spec, raw, probeOpts) : [mgr?.argv(spec, raw, probeOpts) || []];
5109
5266
  const direct = stepList.filter((a) => a.length).map((a) => a.join(" ")).join(" && ");
@@ -5151,14 +5308,306 @@ function verifyRuntimePackages(runtime, resolved, contextDir) {
5151
5308
  return found.filter((x) => (seen.has(x.identity) ? false : seen.add(x.identity))).sort((a, b) => a.identity.localeCompare(b.identity));
5152
5309
  }
5153
5310
 
5311
+
5312
+ // ---------- launch recipes ----------
5313
+ // What a harness start is made of, recorded in instance.json (`launch`) so a
5314
+ // later start or restart can re-render it, select another configuration, or
5315
+ // switch runtime without guessing from the command string. The rendered
5316
+ // command (`command`) stays beside it, byte-identical to what spawn rendered
5317
+ // before recipes existed when no configuration is selected.
5318
+ export const LAUNCH_RECIPE_VERSION = 1;
5319
+ const LAUNCH_PROMPT = { kind: "task-file", file: "TASK.md" };
5320
+
5321
+ /** The executable a launch uses: a configuration's declared one (a bare name
5322
+ * on PATH; a path against the declaring scope when relative) or the
5323
+ * runtime's default (claude through oats-claude-config). Never executed. */
5324
+ export function resolveLaunchExecutable({ runtime, declared, declaringDir, contextDir }) {
5325
+ if (declared) {
5326
+ if (declared.includes("/")) {
5327
+ const path = isAbsolute(declared) ? declared : resolve(declaringDir || contextDir, declared);
5328
+ return { path, declared, resolvedFrom: isAbsolute(declared) ? "absolute" : `relative to ${declaringDir || contextDir}`, missing: existsSync(path) ? undefined : `${declared} (${path}) does not exist` };
5329
+ }
5330
+ const found = which(declared);
5331
+ return { path: found || null, declared, resolvedFrom: "PATH", missing: found ? undefined : `${declared} binary not found on PATH` };
5332
+ }
5333
+ const claudeBin = runtime === "claude" ? resolveClaudeBinary(contextDir) : undefined;
5334
+ const name = runtime === "claude" ? claudeBin : runtime;
5335
+ const found = which(name);
5336
+ return { path: found || null, declared: null, resolvedFrom: runtime === "claude" && claudeBin !== "claude" ? "oats-claude-config" : "PATH", missing: found ? undefined : `${name} binary not found on PATH${claudeBin && claudeBin !== "claude" ? " (named by oats-claude-config)" : ""}` };
5337
+ }
5338
+ /** null when `path` is a regular executable file; otherwise why not. */
5339
+ export function checkLaunchExecutable(path) {
5340
+ try {
5341
+ const st = statSync(path);
5342
+ if (!st.isFile()) return `${path} is not a regular file`;
5343
+ accessSync(path, fsConstants.X_OK);
5344
+ return null;
5345
+ } catch (e) { return `${path} ${e.code === "ENOENT" ? "does not exist" : e.code === "EACCES" ? "is not executable" : e.message}`; }
5346
+ }
5347
+ /** Source names of {fromEnv} references absent from `env`. */
5348
+ export function missingLaunchEnvRefs(configEnv, env = process.env) {
5349
+ return Object.values(configEnv || {}).filter((v) => v && typeof v === "object" && v.fromEnv && env[v.fromEnv] === undefined).map((v) => v.fromEnv).sort();
5350
+ }
5351
+ /** The selection a start makes: which configuration (explicit name, "none",
5352
+ * a frozen recipe's, or the soul's default), and from it the runtime and
5353
+ * model. A named configuration is a unit: an explicit runtime that disagrees
5354
+ * with it is refused. On an existing home, --runtime alone leaves the old
5355
+ * configuration behind (its executable and args are not carried), and a
5356
+ * model never crosses runtimes: explicit or configured model, else the
5357
+ * frozen model on the same runtime, else the runtime's native default. */
5358
+ export function resolveLaunchSelection({ launchConfigs = {}, agent, frozen, selection = {} }) {
5359
+ const bad = (code, msg) => { throw oatsError(code, msg); };
5360
+ let wanted = selection.launchConfig;
5361
+ let config = null;
5362
+ if (wanted === undefined && frozen && !selection.runtime) {
5363
+ // An ordinary start of an existing home runs what was recorded: its
5364
+ // configuration as captured (executable, args, env, references), whether
5365
+ // or not the scope still declares it that way. Only an explicit name
5366
+ // applies the current definition.
5367
+ // Named or not: the recorded executable (a saved wrapper, say), args and
5368
+ // env are what runs; later edits of the scope never change it.
5369
+ config = { name: frozen.launchConfig || null, runtime: frozen.runtime, ...(frozen.executableDeclared ? { executable: frozen.executableDeclared } : {}), executablePath: frozen.executable, args: [...(frozen.args || [])], env: { ...(frozen.env || {}) }, ...(frozen.model ? { model: frozen.model } : {}), ...(frozen.yolo !== undefined ? { yolo: frozen.yolo } : {}), source: frozen.launchConfigSource || null, frozen: true };
5370
+ wanted = "none";
5371
+ }
5372
+ if (wanted === undefined) wanted = frozen ? "none" : (agent?.["launch-config"] || "none");
5373
+ if (wanted !== "none") {
5374
+ if (typeof wanted !== "string" || !Object.hasOwn(launchConfigs, wanted)) bad("E_LAUNCH_CONFIG_UNKNOWN", `no launch configuration ${JSON.stringify(wanted)} is effective here${frozen?.launchConfig === wanted ? " any more (the home was started with it; an unqualified start still runs the recorded one)" : ""}; oats launch-config list shows what is`);
5375
+ config = launchConfigs[wanted];
5376
+ if (selection.runtime && selection.runtime !== config.runtime) bad("E_LAUNCH_CONFIG_MISMATCH", `launch configuration ${wanted} starts ${config.runtime}; --runtime ${selection.runtime} disagrees with it (select another configuration, or --launch-config none with --runtime)`);
5377
+ }
5378
+ const runtime = config?.runtime || selection.runtime || (frozen ? frozen.runtime : agent?.runtime || "pi");
5379
+ if (!LAUNCH_RUNTIMES.includes(runtime)) bad("E_UNSUPPORTED_RUNTIME", `unknown runtime "${runtime}" (pi|claude|codex)`);
5380
+ let model, modelSource;
5381
+ const explicit = selection.model !== undefined && selection.model !== null && String(selection.model).trim() !== "";
5382
+ if (explicit) {
5383
+ model = resolveModelPreference(String(selection.model), runtime); modelSource = "explicit";
5384
+ if (!model) bad("E_MODEL_UNKNOWN", `model preference ${JSON.stringify(selection.model)} has no entry usable by runtime ${runtime}; give a ${runtime} model id`);
5385
+ } else if (config?.model) {
5386
+ model = config.frozen ? config.model : resolveModelPreference(config.model, runtime); modelSource = config.frozen ? "recorded" : `launch-config ${config.name}`;
5387
+ if (!model) bad("E_MODEL_UNKNOWN", `launch configuration ${config.name} names model ${JSON.stringify(config.model)}, which has no entry usable by runtime ${runtime}`);
5388
+ } else if (frozen) {
5389
+ if (frozen.runtime === runtime) { model = frozen.model || ""; modelSource = model ? "recorded" : "native default"; }
5390
+ else { model = ""; modelSource = "native default (runtime changed)"; }
5391
+ } else if (runtime !== (agent?.runtime || "pi")) {
5392
+ // A soul's model preference belongs to the soul's runtime; a bare alias
5393
+ // is no proof it fits another one. Nothing is passed across.
5394
+ model = ""; modelSource = "native default (runtime differs from the soul's)";
5395
+ } else { model = resolveModelPreference(agent?.model || "", runtime); modelSource = model ? "soul default" : "native default"; }
5396
+ return { config, runtime, model, modelSource, configuredYolo: config?.yolo };
5397
+ }
5398
+ /** The pane environment carrying each reference's value under a
5399
+ * kernel-owned alias (OATS_LAUNCH_REF_<NAME>), which no configuration can
5400
+ * name: the command says NAME="$OATS_LAUNCH_REF_NAME", so no source
5401
+ * variable is ever named in the command and no assignment in the same
5402
+ * prefix can shadow it (zsh evaluates a prefix's assignments in order).
5403
+ * Rendered as tmux `-e` flags or a shell export prefix. */
5404
+ export function launchEnvRefs(recipe, env = process.env) {
5405
+ const out = [];
5406
+ for (const [name, v] of Object.entries(recipe.env || {})) if (v && typeof v === "object" && v.fromEnv && env[v.fromEnv] !== undefined) out.push({ name: `${LAUNCH_REF_PREFIX}${name}`, value: env[v.fromEnv], target: name, source: v.fromEnv });
5407
+ return out;
5408
+ }
5409
+ export function launchEnvTmuxFlags(recipe, env) { return launchEnvRefs(recipe, env).map((r) => ` -e ${shq(`${r.name}=${r.value}`)}`).join(""); }
5410
+ export function launchEnvExports(recipe, env) { return launchEnvRefs(recipe, env).map((r) => `export ${r.name}=${shq(r.value)}; `).join(""); }
5411
+
5412
+ /** The harness command line of a recipe. With no configuration the bytes
5413
+ * equal what spawn rendered before recipes: env prefix, the executable, the
5414
+ * runtime's own arguments, capability launch args, the task prompt. A
5415
+ * configuration's args go after the runtime's own options and before
5416
+ * capability args; for claude/codex the `--` separator keeps them from
5417
+ * swallowing the task, for pi they follow the task like capability args.
5418
+ *
5419
+ * pi runs the STRICT CURRICULUM: the OATS-composed skill set and AGENTS.md
5420
+ * only (--no-skills + --skill, --no-context-files, --no-prompt-templates,
5421
+ * --append-system-prompt); extensions stay ambient by founder ruling (no
5422
+ * --no-extensions, no -e), and the task positional goes ahead of contributed
5423
+ * options because pi has no `--`. claude gets `--` before the prompt so a
5424
+ * greedy contributed flag cannot eat it. codex keeps its native policy;
5425
+ * yolo also trusts this generated home for the launch (projects=...). */
5426
+ export function renderLaunchRecipe(recipe, { home, instance, redact = false }) {
5427
+ const { runtime, executable, model, yolo } = recipe;
5428
+ const cfgArgs = (recipe.args || []).map(shq).join(" ");
5429
+ const hookArgs = recipe.hooks?.launch?.[runtime] || "";
5430
+ const tail = `${cfgArgs ? ` ${cfgArgs}` : ""}${hookArgs ? ` ${hookArgs}` : ""}`;
5431
+ let cmdline;
5432
+ if (runtime === "claude") {
5433
+ cmdline = `${shq(executable)}${yolo ? " --dangerously-skip-permissions" : ""}${model ? ` --model ${shq(model)}` : ""}${tail} -- "$(cat TASK.md)"`;
5434
+ } else if (runtime === "codex") {
5435
+ const codexTrust = `projects={${JSON.stringify(realPathOrNearest(home))}={trust_level="trusted"}}`;
5436
+ cmdline = `${shq(executable)} --cd ${shq(home)}${yolo ? ` --yolo -c ${shq(codexTrust)}` : ""}${model ? ` --model ${shq(model)}` : ""}${tail} -- "$(cat TASK.md)"`;
5437
+ } else {
5438
+ cmdline = `${shq(executable)} --no-skills --skill ${shq(join(home, ".agents", "skills"))} --no-context-files --no-prompt-templates --append-system-prompt ${shq(join(home, "AGENTS.md"))} --approve --name ${shq(instance)}${model ? ` --model ${shq(model)}` : ""} ${shq("@TASK.md")}${tail}`;
5439
+ }
5440
+ const hookEnv = recipe.hooks?.env || {};
5441
+ const envTokens = Object.keys(hookEnv).sort().map((name) => `${name}=${shq(redact ? "<redacted>" : hookEnv[name])}`);
5442
+ for (const name of Object.keys(recipe.env || {}).sort()) {
5443
+ const v = recipe.env[name];
5444
+ envTokens.push(typeof v === "string" ? `${name}=${shq(redact ? "<redacted>" : v)}` : `${name}="$${LAUNCH_REF_PREFIX}${name}"`);
5445
+ }
5446
+ return `OATS_INSTANCE=${shq(instance)} OATS_INSTANCE_HOME=${shq(home)} PI_AGENT_INSTANCE=${shq(instance)} PI_AGENT_HOME=${shq(home)}${envTokens.length ? ` ${envTokens.join(" ")}` : ""} ${cmdline}`;
5447
+ }
5448
+
5449
+ /** A recorded recipe this kernel understands, or a refusal before anything
5450
+ * is observed or stopped. */
5451
+ export function assertLaunchRecipe(recipe, what) {
5452
+ const bad = (why) => { throw oatsError("E_LAUNCH_RECIPE_UNSUPPORTED", `${what} records a launch recipe this kernel cannot start from (${why}); inspect launch in its instance.json`); };
5453
+ if (!recipe || typeof recipe !== "object") bad("not an object");
5454
+ if (recipe.version !== LAUNCH_RECIPE_VERSION) bad(`version ${JSON.stringify(recipe.version)}, expected ${LAUNCH_RECIPE_VERSION}`);
5455
+ if (!LAUNCH_RUNTIMES.includes(recipe.runtime)) bad(`runtime ${JSON.stringify(recipe.runtime)}`);
5456
+ if (typeof recipe.executable !== "string" || !recipe.executable) bad("no executable");
5457
+ if (!Array.isArray(recipe.args) || recipe.args.some((a) => typeof a !== "string")) bad("args are not a list of strings");
5458
+ if (!recipe.env || typeof recipe.env !== "object" || Array.isArray(recipe.env)) bad("env is not a map");
5459
+ for (const [n, v] of Object.entries(recipe.env)) if (!(typeof v === "string" || (v && typeof v === "object" && typeof v.fromEnv === "string"))) bad(`env ${n} is neither a string nor a reference`);
5460
+ if (recipe.model !== null && recipe.model !== undefined && typeof recipe.model !== "string") bad("model is not text");
5461
+ if (recipe.yolo !== undefined && typeof recipe.yolo !== "boolean") bad("yolo is not a boolean");
5462
+ if (!recipe.hooks || typeof recipe.hooks !== "object") bad("no hooks record");
5463
+ return recipe;
5464
+ }
5465
+ /** The runtime-package requirements that apply: declared for this runtime
5466
+ * and, for a conditional row, holding under the provider's (captured)
5467
+ * settings. Nothing else is probed or restricted. */
5468
+ export function applicableRequirements(runtime, providers) {
5469
+ const holds = (cap, r) => !r.when || (r.when && typeof r.when === "object" && !Array.isArray(r.when) && Object.entries(r.when).every(([k, v]) => String(cap.settings?.[k] ?? "") === String(v)));
5470
+ const out = [];
5471
+ for (const cap of providers || []) for (const r of cap.manifest?.requires || []) if (r && typeof r === "object" && r.runtime === runtime && holds(cap, r)) out.push({ capability: cap.id, package: r.package });
5472
+ return out;
5473
+ }
5474
+ function requirementsWithArgsMessage(runtime, providers, config) {
5475
+ const rows = applicableRequirements(runtime, providers);
5476
+ return `launch configuration ${config?.name || "(recorded)"} passes arguments (${config.args.map((a) => JSON.stringify(a)).join(", ")}) that the ${runtime} package probe cannot carry, so ${rows.map((r) => `${r.capability}'s requirement ${r.package}`).join(", ")} cannot be verified for that launch; native configuration a required package must see belongs in a wrapper executable or the environment (env / fromEnv), not in args`;
5477
+ }
5478
+ /** ONE planner for what a start would run, used by preview and by starts of
5479
+ * existing homes alike: the recorded recipe (or, under a selection, the
5480
+ * current scoped configuration) resolved, preflighted, rendered. `preview`
5481
+ * collects every failed check into `preflight` instead of throwing. A home
5482
+ * that predates recipes is not planned here (E_LAUNCH_LEGACY): its frozen
5483
+ * command is described as is, and converted only by restart. */
5484
+ export function planLaunch({ home, instance, meta, contextDir, agentLike, selection = {}, launchConfigs, resolvedCfg, env = process.env, preview = false }) {
5485
+ const problems = [];
5486
+ const fail = (check, code, detail) => { if (!preview) throw oatsError(code, detail); problems.push({ check, ok: false, detail, code }); };
5487
+ // A recorded recipe is validated before anything is observed; a home that
5488
+ // predates recipes is converted narrowly from its recorded command (the
5489
+ // kernel's own generated shapes only; environment attributed through the
5490
+ // home's capability declarations); the conversion is recorded by the start
5491
+ // that uses it.
5492
+ const frozen = meta ? (meta.launch && typeof meta.launch === "object" ? assertLaunchRecipe(meta.launch, meta.instance || home) : recipeFromLegacyCommand(meta, home).recipe) : null;
5493
+ const chosen = resolveLaunchSelection({ launchConfigs: launchConfigs || resolvedCfg?.launchConfigs || {}, agent: agentLike, frozen, selection });
5494
+ const { config, runtime, model, modelSource } = chosen;
5495
+ const yolo = resolveYolo(selection.yolo ?? chosen.configuredYolo ?? (frozen ? frozen.yolo : agentLike?.yolo ?? resolvedCfg?.yolo));
5496
+ const executable = config?.frozen
5497
+ ? { path: config.executablePath, declared: frozen.executableDeclared ?? null, resolvedFrom: frozen.executableResolvedFrom || "recorded", missing: existsSync(config.executablePath) ? undefined : `${config.executablePath} (recorded) does not exist` }
5498
+ : resolveLaunchExecutable({ runtime, declared: config?.executable, declaringDir: config?.source, contextDir });
5499
+ const exeProblem = executable.path ? checkLaunchExecutable(executable.path) : executable.missing;
5500
+ if (exeProblem) fail("executable", "E_LAUNCH_EXECUTABLE", `launch configuration ${config?.name || "(runtime default)"}: ${exeProblem}`); else problems.push({ check: "executable", ok: true, detail: `${executable.path} (${executable.resolvedFrom})` });
5501
+ // Capability contributions: recorded at spawn with provenance; a runtime
5502
+ // switch needs the new runtime's launch args from the same capabilities.
5503
+ let hooks = { launch: {}, env: {}, contributions: [], pending: true };
5504
+ if (frozen) {
5505
+ // Recorded contributions, refreshed by capabilities that declare a
5506
+ // launch hook; a runtime change needs the new runtime's arguments from
5507
+ // every capability that gave runtime-specific ones; recorded arguments
5508
+ // of a capability the scope no longer trusts are not reused.
5509
+ try {
5510
+ hooks = prepareLaunchHooks({ frozen, runtime, resolvedCfg, home, meta, contextDir });
5511
+ const current = new Map((resolvedCfg?.capabilities || []).map((c) => [c.id, c]));
5512
+ const untrusted = hooks.contributions.filter((c) => c.capability && current.has(c.capability) && !current.get(c.capability).trust?.trusted).map((c) => c.capability);
5513
+ const inactive = hooks.contributions.filter((c) => c.capability && !current.has(c.capability)).map((c) => c.capability);
5514
+ if (untrusted.length) fail("capabilities", "E_LAUNCH_PREPARATION", `${untrusted.join(", ")} contributed to this launch at spawn but is no longer trusted in the scope; re-trust it (oats trust) or change the binding; nothing was stopped`);
5515
+ else problems.push({ check: "capabilities", ok: true, detail: `${runtime !== frozen.runtime ? "prepared for the new runtime" : "recorded contributions reused"}${hooks.refreshed?.length ? `; refreshed by launch hooks: ${hooks.refreshed.join(", ")}` : ""}${inactive.length ? `; no longer active in the scope, recorded contribution kept: ${inactive.join(", ")}` : ""}` });
5516
+ } catch (e) {
5517
+ if (!e.code || !["E_LAUNCH_PREPARATION", "E_LAUNCH_LEGACY"].includes(e.code)) throw e;
5518
+ fail("capabilities", e.code, e.message);
5519
+ hooks = { launch: { ...(frozen.hooks?.launch || {}) }, env: { ...(frozen.hooks?.env || {}) }, contributions: frozen.hooks?.contributions || [] };
5520
+ }
5521
+ } else problems.push({ check: "capabilities", ok: true, detail: "decided by the capabilities' spawn hooks" });
5522
+ // A configuration may not override environment a capability owns.
5523
+ const configEnv = config?.env || {};
5524
+ const owned = Object.keys(configEnv).filter((n) => Object.hasOwn(hooks.env, n));
5525
+ if (owned.length) {
5526
+ const owner = (n) => hooks.contributions.find((c) => (c.env || []).includes(n))?.capability || "a capability";
5527
+ fail("environment", "E_LAUNCH_ENV_CONFLICT", `${owned.map((n) => `${n} (set by ${owner(n)})`).join(", ")} cannot be overridden by launch configuration ${config?.name}; change the capability's setting instead`);
5528
+ }
5529
+ const missing = missingLaunchEnvRefs(configEnv, env);
5530
+ if (missing.length) fail("environment", "E_LAUNCH_ENV_MISSING", `launch configuration ${config?.name} references ${missing.join(", ")}, not set on this host; nothing was stopped or started`);
5531
+ else if (!owned.length) problems.push({ check: "environment", ok: true, detail: `${Object.keys(configEnv).length} value(s), references resolved on the execution host at start` });
5532
+ problems.push({ check: "model", ok: true, detail: model ? `${model} (${modelSource})` : `native default (${modelSource})` });
5533
+ // Runtime package requirements: the captured providers' (bindings united
5534
+ // with contributions, by id, current manifest, captured settings for
5535
+ // conditional rows) for an existing home, the scope's for a new instance;
5536
+ // probed under the launch's EFFECTIVE environment with the selected
5537
+ // executable, only when some requirement is declared for this runtime and
5538
+ // every reference resolved. Configuration arguments cannot be applied to a
5539
+ // package probe (it must never start a conversation), which is stated.
5540
+ if (executable.path && !missing.length && !owned.length) {
5541
+ const providers = frozen
5542
+ ? capturedProviders(meta, frozen).map((p) => { const manifest = contextDir ? capabilityManifest(p.id, contextDir) : undefined; return manifest ? { id: p.id, manifest, settings: p.settings } : null; }).filter(Boolean)
5543
+ : (resolvedCfg?.capabilities || []);
5544
+ const declared = applicableRequirements(runtime, providers).length > 0;
5545
+ if (declared && (config?.args || []).length) {
5546
+ // The controlled probe cannot carry configuration arguments, so with
5547
+ // an applicable requirement a launch under such arguments cannot be
5548
+ // reported verified: refused, truthfully, with the way out.
5549
+ fail("runtime-packages", "E_LAUNCH_PROBE_UNSUPPORTED", requirementsWithArgsMessage(runtime, providers, config));
5550
+ } else if (declared) {
5551
+ try {
5552
+ verifyRuntimePackages(runtime, { capabilities: providers }, contextDir, { ...(config?.executable || config?.frozen ? { bin: executable.path } : {}), env: launchEffectiveEnv({ base: env, hooksEnv: hooks.env, configEnv }) });
5553
+ problems.push({ check: "runtime-packages", ok: true, detail: `verified with ${executable.path}${(config?.args || []).length ? "; configuration arguments are not applied to the probe: native configuration the packages must see belongs in a wrapper or the environment" : ""}` });
5554
+ } catch (e) { fail("runtime-packages", "E_RUNTIME_PACKAGE", e.message); }
5555
+ } else problems.push({ check: "runtime-packages", ok: true, detail: "no runtime package requirement declared for this runtime; nothing probed" });
5556
+ }
5557
+ const recipe = {
5558
+ version: LAUNCH_RECIPE_VERSION, runtime, launchConfig: config?.name || null, launchConfigSource: config?.source || null,
5559
+ executable: executable.path || executable.declared || runtime, executableDeclared: executable.declared ?? null, executableResolvedFrom: executable.resolvedFrom,
5560
+ args: [...(config?.args || [])], env: { ...configEnv }, model: model || null, ...(yolo !== undefined ? { yolo } : {}),
5561
+ hooks: frozen ? { launch: hooks.launch, env: hooks.env, contributions: hooks.contributions } : hooks, prompt: LAUNCH_PROMPT,
5562
+ ...(frozen?.legacy ? { legacy: { ...frozen.legacy, ...(hooks.refreshed?.length ? { replacedBy: hooks.refreshed } : {}) } } : {}),
5563
+ };
5564
+ const inst = instance || meta?.instance || basename(home);
5565
+ const command = renderLaunchRecipe(recipe, { home, instance: inst });
5566
+ const selectionSource = frozen ? (config?.frozen || (!config && !selection.launchConfig && !selection.runtime) ? "frozen" : "config") : "config";
5567
+ return { recipe, command, runtime, model: model || undefined, modelSource, yolo, config, executable, preflight: problems, ok: problems.every((c) => c.ok), selectionSource, frozen };
5568
+ }
5569
+ /** The environment a planned launch runs under: the host's base, the
5570
+ * capabilities' validated env, the configuration's literals and its
5571
+ * references resolved from the BASE (never from the prefix beside them).
5572
+ * Package probes run under exactly this, so a CLAUDE_CONFIG_DIR or a
5573
+ * credential reference selects the same account the launch will. */
5574
+ export function launchEffectiveEnv({ base = process.env, hooksEnv = {}, configEnv = {} } = {}) {
5575
+ const out = { ...base, ...hooksEnv };
5576
+ for (const [name, v] of Object.entries(configEnv || {})) {
5577
+ if (typeof v === "string") out[name] = v;
5578
+ else if (v && typeof v === "object" && v.fromEnv && base[v.fromEnv] !== undefined) out[name] = base[v.fromEnv];
5579
+ }
5580
+ return out;
5581
+ }
5582
+ /** argv (after the executable) and environment names of a rendered command,
5583
+ * from the parser: what a GUI shows, never the TASK body. */
5584
+ export function describeLaunchCommand(command) {
5585
+ const { tokens, binary } = parseLaunchCommand(command);
5586
+ const environment = tokens.slice(0, binary).map((t) => t.kind === "envref" ? { name: t.name, reference: true, ...(t.source.startsWith(LAUNCH_REF_PREFIX) ? {} : { fromEnv: t.source }) } : { name: t.name, redacted: true });
5587
+ const argv = tokens.slice(binary + 1).map((t) => t.kind === "sep" ? "--" : t.kind === "prompt" ? t.text : t.value);
5588
+ return { executable: tokens[binary].value, argv, environment };
5589
+ }
5590
+ /** A persisted command with every environment value withheld (references
5591
+ * stay references); a command the parser refuses is withheld whole. */
5592
+ /** The identity environment every launch carries: public facts (instance
5593
+ * name, home path), shown in public renderings; everything else is withheld. */
5594
+ export const IDENTITY_LAUNCH_ENV = new Set(["OATS_INSTANCE", "OATS_INSTANCE_HOME", "PI_AGENT_INSTANCE", "PI_AGENT_HOME"]);
5595
+ export function redactLaunchCommand(command) {
5596
+ try { return parseLaunchCommand(command).tokens.map((t) => t.kind === "env" && !IDENTITY_LAUNCH_ENV.has(t.name) ? `${t.name}='<redacted>'` : t.text).join(" "); }
5597
+ catch { return "<unparseable launch command withheld>"; }
5598
+ }
5599
+ /** The recipe with every environment value withheld. */
5600
+ export function redactLaunchRecipe(recipe) {
5601
+ const env = Object.fromEntries(Object.keys(recipe.env || {}).sort().map((n) => [n, typeof recipe.env[n] === "string" ? { redacted: true } : { fromEnv: recipe.env[n].fromEnv }]));
5602
+ const hooks = recipe.hooks ? { ...recipe.hooks, env: Object.fromEntries(Object.keys(recipe.hooks.env || {}).sort().map((n) => [n, { redacted: true }])) } : undefined;
5603
+ return { ...recipe, env, ...(hooks ? { hooks } : {}) };
5604
+ }
5154
5605
  export function spawnInstance(root, agent, o = {}) {
5155
5606
  const work = o.work || agent.work || "checkout";
5156
5607
  if (!WORK_MODES.includes(work)) throw new Error(`unknown work mode "${work}" (${WORK_MODES.join("|")})`);
5157
5608
  if (work === "attached" && !o.workDir) throw new Error(`attached mode needs workDir — the owning instance's work tree (its <home>/work)`);
5158
5609
  if (o.task !== undefined && typeof o.task !== "string") throw new Error(`task must be a string (got ${typeof o.task}) — a flag parser handing --task's next flag through shows up here`);
5159
- const runtime = o.runtime || agent.runtime || "pi";
5160
- if (!["pi", "claude", "codex"].includes(runtime)) throw oatsError("E_UNSUPPORTED_RUNTIME", `unknown runtime "${runtime}" (pi|claude|codex)`);
5161
- const model = resolveModelPreference(o.model || agent.model || "", runtime);
5610
+ if (o.launchConfig !== undefined && (typeof o.launchConfig !== "string" || !o.launchConfig.trim())) throw oatsError("E_BAD_ARGS", "launchConfig must be a configuration name or none");
5162
5611
  const session = o.tmuxSession || DEFAULT_TMUX_SESSION;
5163
5612
  const backend = o.backend || agent.backend || "tmux";
5164
5613
  if (!["tmux", "herdr"].includes(backend)) throw new Error(`unknown session backend "${backend}" (tmux|herdr)`);
@@ -5166,6 +5615,12 @@ export function spawnInstance(root, agent, o = {}) {
5166
5615
  const launch = o.launch !== false;
5167
5616
  const repoAbs = resolveRepo(root, o.repo || agent.repo);
5168
5617
  if (!repoAbs) throw new Error(`agent "${agent.name}" has no repo configured — pass one`);
5618
+ // Launch selection: a named configuration (explicit, or the soul's
5619
+ // launch-config default), or none; the runtime and model follow from it.
5620
+ const launchSelection = resolveLaunchSelection({ launchConfigs: launchConfigsOf(configChain(repoAbs)), agent, selection: { launchConfig: o.launchConfig, runtime: o.runtime, model: o.model } });
5621
+ const launchConfig = launchSelection.config;
5622
+ const runtime = launchSelection.runtime;
5623
+ const model = launchSelection.model;
5169
5624
  // Instance homes belong in the soul-owning repo's PRIMARY checkout, never in a
5170
5625
  // linked worktree (see canonicalDeploymentPath). The CLI resolves this through
5171
5626
  // ensureRoot, but the kernel is its own validation boundary — the desktop
@@ -5468,7 +5923,7 @@ export function spawnInstance(root, agent, o = {}) {
5468
5923
  const soulDir = agent._soulDir || soulOf(agent._dir);
5469
5924
  const composition = composeInstanceAgentsMd(soulDir, repoAbs, agent.name, work, agent.kind);
5470
5925
  const resolvedCfg = composition.resolved;
5471
- const yolo = resolveYolo(o.yolo ?? agent.yolo ?? resolvedCfg.yolo);
5926
+ const yolo = resolveYolo(o.yolo ?? launchSelection.configuredYolo ?? agent.yolo ?? resolvedCfg.yolo);
5472
5927
  const expectedResources = planInstanceResources({ resolved: resolvedCfg, soulDir, agent, contextDir: repoAbs, composition });
5473
5928
  // Runtime extensions selected by ACTIVE capabilities for THIS instance's
5474
5929
  // runtime. Strict launch disables ambient extension discovery, so each one has
@@ -5476,12 +5931,21 @@ export function spawnInstance(root, agent, o = {}) {
5476
5931
  // must fail here, loudly, rather than produce an instance that silently lost
5477
5932
  // its channel. `--runtime` can override a soul default long after install-time
5478
5933
  // reconciliation, so this spawn-time check is the authoritative one.
5479
- const runtimePackages = verifyRuntimePackages(runtime, resolvedCfg, repoAbs);
5480
5934
 
5481
5935
  // Prerequisites must fail before creating a home, worktree, or identity.
5482
- const claudeBin = runtime === "claude" ? resolveClaudeBinary(repoAbs) : undefined;
5483
- const bin = which(runtime === "claude" ? claudeBin : runtime);
5484
- if (!bin) throw new Error(`${runtime === "claude" ? claudeBin : runtime} binary not found on PATH${claudeBin && claudeBin !== "claude" ? " (named by oats-claude-config)" : ""}`);
5936
+ const executable = resolveLaunchExecutable({ runtime, declared: launchConfig?.executable, declaringDir: launchConfig?.source, contextDir: repoAbs });
5937
+ if (!executable.path) throw new Error(executable.missing);
5938
+ { const bad = checkLaunchExecutable(executable.path); if (bad) throw oatsError("E_LAUNCH_EXECUTABLE", `launch configuration ${launchConfig?.name || "(runtime default)"}: ${bad}`); }
5939
+ const bin = executable.path;
5940
+ const missingRefs = missingLaunchEnvRefs(launchConfig?.env || {}, process.env);
5941
+ if (missingRefs.length) throw oatsError("E_LAUNCH_ENV_MISSING", `launch configuration ${launchConfig.name} references ${missingRefs.join(", ")}, not set in this environment; nothing was created`);
5942
+ // A configuration's executable answers the package probe (without one the
5943
+ // context-selected name does, as before: its remedies name that command),
5944
+ // under the configuration's environment (literals, references resolved
5945
+ // from the base); the capabilities' own env is not known before their
5946
+ // spawn hooks run, which happens after the home exists.
5947
+ if ((launchConfig?.args || []).length && applicableRequirements(runtime, resolvedCfg.capabilities).length) throw oatsError("E_LAUNCH_PROBE_UNSUPPORTED", `${requirementsWithArgsMessage(runtime, resolvedCfg.capabilities, launchConfig)}; nothing was created`);
5948
+ const runtimePackages = verifyRuntimePackages(runtime, resolvedCfg, repoAbs, { ...(launchConfig?.executable ? { bin } : {}), env: launchEffectiveEnv({ base: process.env, configEnv: launchConfig?.env || {} }) });
5485
5949
  if (launch && !which(backend)) throw new Error(`${backend} not installed${backend === "tmux" ? " (brew install tmux)" : " (https://herdr.dev)"}`);
5486
5950
  const herdrBase = launch && backend === "herdr" ? ensureHerdr({ binary: which("herdr"), socket: o.herdrSocket }) : undefined;
5487
5951
  const task = o.task ?? (o.taskFile ? readFileSync(o.taskFile, "utf8") : "");
@@ -5805,6 +6269,13 @@ export function spawnInstance(root, agent, o = {}) {
5805
6269
  const code = requiredFailures.some((f) => f.contract === "environment") ? "E_HOOK_ENVIRONMENT_CONTRACT" : "E_REQUIRED_HOOK_FAILED";
5806
6270
  throw oatsError(code, `a capability this soul activates could not configure itself:\n${detail}\n\nThe instance would have started with an invalid or missing capability configuration`);
5807
6271
  }
6272
+ {
6273
+ const owned = Object.keys(launchConfig?.env || {}).filter((n) => Object.hasOwn(hookRes.env, n));
6274
+ if (owned.length) {
6275
+ const owner = (n) => (hookRes.contributions || []).find((c) => (c.env || []).includes(n))?.capability || "a capability";
6276
+ throw oatsError("E_LAUNCH_ENV_CONFLICT", `${owned.map((n) => `${n} (set by ${owner(n)})`).join(", ")} cannot be overridden by launch configuration ${launchConfig.name}; change the capability's setting instead`);
6277
+ }
6278
+ }
5808
6279
  const briefLines = hookRes.briefs.length ? `\n${hookRes.briefs.join("\n")}` : "";
5809
6280
  const workDesc = work === "worktree"
5810
6281
  ? `a dedicated git worktree of ${repoAbs} on branch "${branch}" — commit freely there`
@@ -5821,84 +6292,20 @@ You are instance "${instance}" of agent "${agent.name}".
5821
6292
  - Do all repository work inside ./work. Read ./work/AGENTS.md or ./work/CLAUDE.md first if present.${briefLines}${runtime === "codex" ? "\n## Runtime notification delivery\n\nNative Codex has no built-in messaging channel. Follow the explicit delivery briefing for this instance from your messaging capability, if present; it may arrange notification through this terminal. Shared channel instructions alone do not establish that delivery is configured. Without an instance delivery briefing, check your messaging capability's inbox and pending commands at task boundaries or when the operator asks; do not assume messages will wake this session.\n" : ""}
5822
6293
  ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spawn time — await instructions.\n"}`);
5823
6294
 
5824
- // Launch command. Spawn IS session start: this command is persisted in
5825
- // instance.json and executed in the instance's tmux window. Capabilities may
5826
- // contribute runtime-specific arguments via their spawn hook's `launch` map
5827
- // (e.g. aweb's Claude Code channel plugin flags).
5828
- const hookArgs = hookRes.launch?.[runtime] ? ` ${hookRes.launch[runtime]}` : "";
5829
- let cmdline;
5830
- if (runtime === "claude") {
5831
- // .claude/skills already links the OATS-composed instance skill set.
5832
- // "--" terminates option parsing BEFORE the prompt: capability launch
5833
- // hooks can contribute greedy/variadic flags (e.g. aweb's
5834
- // --dangerously-load-development-channels), and without the separator
5835
- // the TASK.md text is swallowed as that flag's next value — claude
5836
- // errors out ("entries must be tagged: <task text>") and the window
5837
- // drops to the fallback shell, which reads as a silently stuck spawn.
5838
- cmdline = `${shq(bin)}${yolo ? " --dangerously-skip-permissions" : ""}${model ? ` --model ${shq(model)}` : ""}${hookArgs} -- "$(cat TASK.md)"`;
5839
- } else if (runtime === "codex") {
5840
- // Codex discovers this home's AGENTS.md and .agents/skills natively.
5841
- // Keep the operator's native permission policy. --add-dir is not valid
5842
- // under every policy (including untrusted/read-only startup). Worktrees
5843
- // already live below home; external paths use native approval handling.
5844
- // --yolo bypasses execution approvals but Codex still asks to trust a new
5845
- // project. The operator's explicit yolo choice also trusts this generated
5846
- // home for this launch, without editing the shared user config.
5847
- const codexTrust = `projects={${JSON.stringify(realPathOrNearest(home))}={trust_level="trusted"}}`;
5848
- cmdline = `${shq(bin)} --cd ${shq(home)}${yolo ? ` --yolo -c ${shq(codexTrust)}` : ""}`
5849
- + `${model ? ` --model ${shq(model)}` : ""}${hookArgs} -- "$(cat TASK.md)"`;
5850
- } else {
5851
- // STRICT CURRICULUM (pi): the OATS-composed set — no user, ancestor, project
5852
- // or package skill catalogs, and no auto-discovered AGENTS.md/CLAUDE.md.
5853
- // NOT "nothing else can contribute": extensions stay ambient by founder
5854
- // ruling (see below), and an extension's resources_discover hook can add
5855
- // skill paths that survive --no-skills. The OATS-managed root is exact; the
5856
- // extension surface is the operator's, and stating otherwise here would
5857
- // contradict the paragraph twelve lines down (reviewer-aggregate2).
5858
- //
5859
- // --no-skills ends discovery; --skill stays additive.
5860
- // --no-context-files stops ancestor AGENTS.md/CLAUDE.md auto-injection.
5861
- // It also stops the instance's OWN composed AGENTS.md
5862
- // loading, so that is delivered explicitly via
5863
- // --append-system-prompt. The work tree's AGENTS.md
5864
- // stays READABLE by the read tool: readable, not
5865
- // auto-injected, is the contract.
5866
- // --no-prompt-templates same posture for ambient prompt templates.
5867
- //
5868
- // Built-in tools and pi's native interaction model are untouched — OATS
5869
- // curates the curriculum, it does not cripple the runtime.
5870
- //
5871
- // EXTENSIONS STAY AMBIENT, by founder ruling: operators run cross-agent pi
5872
- // extensions (web search, output formatting) that every instance should
5873
- // keep. So no --no-extensions, and no -e flags either — pi discovers the
5874
- // installed extensions itself, and passing them explicitly as well would
5875
- // load the same extension twice.
5876
- //
5877
- // The trade-off is deliberate and narrow: an extension's
5878
- // `resources_discover` hook can contribute skill paths that survive
5879
- // --no-skills. Today only the OATS bridge does that, and inside an instance
5880
- // it contributes that instance's OWN .agents/skills, so the composed set is
5881
- // unchanged. A third-party extension that contributes skills WOULD add them,
5882
- // which is the accepted residue of keeping shared extensions working.
5883
- // Capability-required runtime packages are still verified and recorded
5884
- // (verifyRuntimePackages), so "aweb on pi requires the aweb pi package"
5885
- // still holds — it is loaded by pi's own discovery rather than by flag.
5886
- // pi has no `--` end-of-options marker (it rejects `--` in every position),
5887
- // so the task positional goes AHEAD of capability-contributed options:
5888
- // nothing preceding it is waiting for a value, so a trailing variadic
5889
- // contributed flag cannot consume the task.
5890
- cmdline = `${shq(bin)} --no-skills --skill ${shq(join(home, ".agents", "skills"))}`
5891
- + ` --no-context-files --no-prompt-templates`
5892
- + ` --append-system-prompt ${shq(join(home, "AGENTS.md"))}`
5893
- + ` --approve --name ${shq(instance)}${model ? ` --model ${shq(model)}` : ""} ${shq("@TASK.md")}${hookArgs}`;
5894
- }
5895
- // OATS_INSTANCE_HOME is the runtime-neutral contract name (absolute path to
5896
- // the instance home) exported to EVERY runtime. PI_AGENT_HOME/PI_AGENT_INSTANCE
5897
- // are pi-branded predecessors kept as compatibility aliases: the separately
5898
- // published @awebai/oats-pi extension and bin/oats.mjs still read them, and
5899
- // an older installed extension must keep working against a newer kernel.
5900
- const hookEnv = Object.keys(hookRes.env).sort().map((name) => `${name}=${shq(hookRes.env[name])}`).join(" ");
5901
- cmdline = `OATS_INSTANCE=${shq(instance)} OATS_INSTANCE_HOME=${shq(home)} PI_AGENT_INSTANCE=${shq(instance)} PI_AGENT_HOME=${shq(home)}${hookEnv ? ` ${hookEnv}` : ""} ${cmdline}`;
6295
+ // Launch command. Spawn IS session start: the recipe is persisted in
6296
+ // instance.json beside its rendering, which is executed in the instance's
6297
+ // tmux window. Capabilities contributed runtime-specific arguments and
6298
+ // environment through their spawn hook; both are recorded with provenance.
6299
+ const recipe = {
6300
+ version: LAUNCH_RECIPE_VERSION, runtime,
6301
+ launchConfig: launchConfig?.name || null, launchConfigSource: launchConfig?.source || null,
6302
+ executable: bin, executableDeclared: executable.declared, executableResolvedFrom: executable.resolvedFrom,
6303
+ args: [...(launchConfig?.args || [])], env: { ...(launchConfig?.env || {}) },
6304
+ model: model || null, ...(yolo !== undefined ? { yolo } : {}),
6305
+ hooks: { launch: { ...hookRes.launch }, env: { ...hookRes.env }, contributions: hookRes.contributions || [] },
6306
+ prompt: LAUNCH_PROMPT,
6307
+ };
6308
+ const cmdline = renderLaunchRecipe(recipe, { home, instance });
5902
6309
 
5903
6310
  const meta = {
5904
6311
  agent: agent.name, kind: agent.kind || "persistent", instance, home,
@@ -5965,7 +6372,7 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
5965
6372
  executable: cap.executable,
5966
6373
  })),
5967
6374
  ...(backend === "herdr" ? { backend } : { tmux: { session, window: instance } }),
5968
- command: cmdline, createdAt: new Date().toISOString(),
6375
+ launch: recipe, command: cmdline, createdAt: new Date().toISOString(),
5969
6376
  };
5970
6377
  const spawnWarnings = warnings;
5971
6378
 
@@ -5977,7 +6384,8 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
5977
6384
  meta.launched = true;
5978
6385
  writeFileSync(join(home, "instance.json"), JSON.stringify(meta, null, 2) + "\n");
5979
6386
  writeRetirementBaseline(home, join(home, "work"), work === "worktree", wm, resolvedCfg.capabilities, { launched: true, sessionTarget: spawnHerdr });
5980
- launchHerdr(spawnHerdr, cmdline);
6387
+ try { launchHerdr(spawnHerdr, `${launchEnvExports(recipe, process.env)}${cmdline}`); }
6388
+ catch (e) { throw oatsError("E_SPAWN_LAUNCH_FAILED", `Herdr could not run the launch command for ${instance} (${e.code === "ENOENT" ? "herdr unavailable" : "pane run failed"}); the command line is withheld from this message`); }
5981
6389
  } else if (launch) {
5982
6390
  if (!tmuxAlive(session)) {
5983
6391
  const hq = existsSync(root) ? root : workspaceOf(root); // all-local scopes may have no agents/ dir
@@ -5996,7 +6404,8 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
5996
6404
  // agent exits (e.g. Ctrl-C) instead of tmux killing the window.
5997
6405
  const windowCmd = `${cmdline}; exec "\${SHELL:-/bin/zsh}"`;
5998
6406
  windowMayExist = true;
5999
- sh(`tmux new-window -t ${shq(session)} -n ${shq(instance)} -c ${shq(home)} ${shq(windowCmd)}`);
6407
+ try { sh(`tmux new-window -t ${shq(session)} -n ${shq(instance)} -c ${shq(home)}${launchEnvTmuxFlags(recipe, process.env)} ${shq(windowCmd)}`); }
6408
+ catch (e) { throw oatsError("E_SPAWN_LAUNCH_FAILED", `tmux new-window failed for ${instance} (${e.code === "ENOENT" ? "tmux unavailable" : "the window command was refused"}); the command line and tmux's output are withheld from this message because they can carry reference values; run tmux list-windows on the session to inspect`); }
6000
6409
  } else {
6001
6410
  meta.launched = false;
6002
6411
  writeFileSync(join(home, "instance.json"), JSON.stringify(meta, null, 2) + "\n");
@@ -6032,7 +6441,7 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
6032
6441
  }
6033
6442
  }
6034
6443
 
6035
- return { ...meta, attach: spawnHerdr ? `HERDR_SOCKET_PATH=${shq(spawnHerdr.socket)} ${shq(spawnHerdr.binary)} terminal attach ${shq(spawnHerdr.terminalId)}` : backend === "herdr" ? "not launched" : `tmux attach -t ${session}`, warnings: spawnWarnings.length ? spawnWarnings : undefined };
6444
+ return { ...meta, launch: redactLaunchRecipe(recipe), command: redactLaunchCommand(cmdline), attach: spawnHerdr ? `HERDR_SOCKET_PATH=${shq(spawnHerdr.socket)} ${shq(spawnHerdr.binary)} terminal attach ${shq(spawnHerdr.terminalId)}` : backend === "herdr" ? "not launched" : `tmux attach -t ${session}`, warnings: spawnWarnings.length ? spawnWarnings : undefined };
6036
6445
  } catch (error) {
6037
6446
  const note = compensateSpawn();
6038
6447
  error.message += note;
@@ -6077,7 +6486,7 @@ export function listInstances(root, tmuxSession = DEFAULT_TMUX_SESSION) {
6077
6486
  try { const state = inspectHerdr({ ...meta.sessionTarget, binary: "herdr" }); liveness = { running: state.present, runtimeState: state.status }; }
6078
6487
  catch (error) { liveness = { running: null, runtimeState: "unreachable", runtimeError: error.message }; }
6079
6488
  }
6080
- return { ...meta, ...liveness, ...(rollbackIncomplete ? { rollbackIncomplete } : {}), ...(retirePending ? { retirePending } : {}) };
6489
+ return { ...meta, ...(meta.launch && typeof meta.launch === "object" ? { launch: redactLaunchRecipe(meta.launch) } : {}), ...(typeof meta.command === "string" ? { command: redactLaunchCommand(meta.command) } : {}), ...liveness, ...(rollbackIncomplete ? { rollbackIncomplete } : {}), ...(retirePending ? { retirePending } : {}) };
6081
6490
 
6082
6491
  });
6083
6492
  };
@@ -6512,6 +6921,10 @@ export function parseLaunchCommand(command) {
6512
6921
  tokens.push({ kind: "prompt", text: LAUNCH_PROMPT_TOKEN });
6513
6922
  continue;
6514
6923
  }
6924
+ // NAME="$SOURCE": an environment reference, resolved on the execution
6925
+ // host when the command runs; the persisted command carries no value.
6926
+ const ref = /^([A-Za-z_][A-Za-z0-9_]*)="\$([A-Za-z_][A-Za-z0-9_]*)"(?= |$)/.exec(command.slice(i));
6927
+ if (ref) { tokens.push({ kind: "envref", name: ref[1], source: ref[2], text: ref[0] }); i += ref[0].length; continue; }
6515
6928
  let envName;
6516
6929
  const m = /^([A-Za-z_][A-Za-z0-9_]*)='/.exec(command.slice(i));
6517
6930
  if (m) { envName = m[1]; i += envName.length + 1; }
@@ -6541,7 +6954,7 @@ export function parseLaunchCommand(command) {
6541
6954
  }
6542
6955
  let binary = -1;
6543
6956
  for (let k = 0; k < tokens.length; k++) {
6544
- if (tokens[k].kind === "env") { if (binary >= 0) bad(0, "env assignment after the binary"); continue; }
6957
+ if (tokens[k].kind === "env" || tokens[k].kind === "envref") { if (binary >= 0) bad(0, "env assignment after the binary"); continue; }
6545
6958
  if (binary < 0) { if (tokens[k].kind !== "word" || !tokens[k].quoted) bad(0, "no quoted binary after the env prefix"); binary = k; }
6546
6959
  }
6547
6960
  if (binary < 0) bad(0, "no binary");
@@ -6583,6 +6996,237 @@ function writeJsonAtomic(path, value, mode) {
6583
6996
  renameSync(tmp, path);
6584
6997
  }
6585
6998
 
6999
+
7000
+ // ---------- stopping a harness, selecting on an existing home ----------
7001
+ const SHELL_NAMES = new Set(["sh", "bash", "zsh", "fish", "dash", "ksh", "tcsh", "csh", "login"]);
7002
+ /** The harness processes under a session target: for tmux EVERY descendant
7003
+ * of the pane's launcher process (the launcher shell itself is left so
7004
+ * that, once its command ends, it becomes the fallback shell the start
7005
+ * path recognizes); a wrapper that does not exec the harness, the harness
7006
+ * and their children are all included, topmost first. For Herdr the
7007
+ * pane's foreground processes. Each row: { pid, ppid, pgid, comm, depth }. */
7008
+ export function harnessProcesses(target, io) {
7009
+ const ps = () => ((io?.exec || execFileSync)("ps", ["-axo", "pid=,ppid=,pgid=,comm="], { encoding: "utf8", timeout: 10000, maxBuffer: 4 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"] }))
7010
+ .split("\n").map((l) => l.trim().match(/^(\d+)\s+(\d+)\s+(\d+)\s+(.+)$/)).filter(Boolean)
7011
+ .map(([, pid, ppid, pgid, comm]) => ({ pid: Number(pid), ppid: Number(ppid), pgid: Number(pgid), comm: basename(comm).replace(/^-/, "") }));
7012
+ if (target.backend === "herdr") {
7013
+ // Herdr's PaneProcessInfo (installed schema: shell_pid, nullable;
7014
+ // foreground_process_group_id; foreground_processes) names the pane's
7015
+ // shell. That pid, verified against this host, is the root: OATS launched
7016
+ // `exec /bin/sh -c <command>` in it, so the root is the launcher shell
7017
+ // and the harness its child, exactly as under tmux; a root whose exec
7018
+ // replaced it with a non-shell IS the harness and is included. Without a
7019
+ // shell_pid, the verified non-shell foreground processes are the roots.
7020
+ // Never a fabricated row: an unverifiable pid refuses before any signal.
7021
+ const info = herdrCommand(target, ["pane", "process-info", "--pane", target.paneId], io).process_info;
7022
+ if (!info || typeof info !== "object") throw oatsError("E_SESSION_UNKNOWN", "Herdr returned no process information for the pane");
7023
+ const rows = ps();
7024
+ const byParent = new Map();
7025
+ for (const r of rows) { if (!byParent.has(r.ppid)) byParent.set(r.ppid, []); byParent.get(r.ppid).push(r); }
7026
+ const out = [];
7027
+ const walk = (pid, depth) => { for (const child of byParent.get(pid) || []) { if (!out.some((o) => o.pid === child.pid)) { out.push({ ...child, depth }); walk(child.pid, depth + 1); } } };
7028
+ const verified = (pid, what) => {
7029
+ if (!Number.isInteger(pid) || pid <= 0) throw oatsError("E_SESSION_UNKNOWN", `Herdr reported ${what} without a verifiable pid; nothing was signalled`);
7030
+ const row = rows.find((r) => r.pid === pid);
7031
+ if (!row) throw oatsError("E_SESSION_UNKNOWN", `Herdr reports ${what} pid ${pid} but this host has no such process; nothing was signalled`);
7032
+ return row;
7033
+ };
7034
+ if (info.shell_pid !== null && info.shell_pid !== undefined) {
7035
+ const root = verified(info.shell_pid, "the pane shell");
7036
+ if (!SHELL_NAMES.has(root.comm)) out.push({ ...root, depth: 0 });
7037
+ walk(root.pid, 1);
7038
+ return out;
7039
+ }
7040
+ if (!Array.isArray(info.foreground_processes)) throw oatsError("E_SESSION_UNKNOWN", "Herdr returned neither a pane shell pid nor foreground processes");
7041
+ for (const p of info.foreground_processes) {
7042
+ const row = verified(p.pid, `foreground process ${p.name || "?"}`);
7043
+ if (SHELL_NAMES.has(row.comm)) { walk(row.pid, 1); continue; }
7044
+ if (!out.some((o) => o.pid === row.pid)) { out.push({ ...row, depth: 0 }); walk(row.pid, 1); }
7045
+ }
7046
+ return out;
7047
+ }
7048
+ const row = tmuxOn(target.socket, ["list-panes", "-t", `=${target.session}:=${target.window}`, "-F", "#{pane_pid}"], io).trim().split("\n")[0];
7049
+ if (!/^\d+$/.test(row || "")) throw oatsError("E_SESSION_UNKNOWN", "tmux returned no pane process id");
7050
+ const panePid = Number(row);
7051
+ const rows = ps();
7052
+ const byParent = new Map();
7053
+ for (const r of rows) { if (!byParent.has(r.ppid)) byParent.set(r.ppid, []); byParent.get(r.ppid).push(r); }
7054
+ const out = [];
7055
+ const walk = (pid, depth) => { for (const child of byParent.get(pid) || []) { out.push({ ...child, depth }); walk(child.pid, depth + 1); } };
7056
+ walk(panePid, 1);
7057
+ return out;
7058
+ }
7059
+ /** Ask the harness under `target` to end: SIGTERM to every process found
7060
+ * under the pane's launcher, one by one, topmost first (no process-group
7061
+ * signalling: the launcher shell must survive to become the fallback
7062
+ * shell), then a bounded wait for the signalled processes to be gone and
7063
+ * the session to read as stopped or a bare shell. Nothing is escalated: a
7064
+ * harness still there when the wait ends is reported as such, still
7065
+ * running. Elapsed time is never taken as exit. */
7066
+ export function stopHarness(target, { graceMs = 20000, signal = "SIGTERM", io = {}, kill = process.kill, sleep } = {}) {
7067
+ const wait = sleep || ((ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms));
7068
+ const before = inspectSessionTarget(target, io);
7069
+ if (!before.present || before.state === "shell" || before.state === "stopped") return { requested: [], signal, sentAt: null, observedAt: new Date().toISOString(), exited: true, waitedMs: 0, state: before.state, note: "nothing was running" };
7070
+ const procs = harnessProcesses(target, io);
7071
+ if (!procs.length) throw oatsError("E_SESSION_UNKNOWN", `the session reads as ${before.state} but no harness process was found under its pane; nothing was signalled`);
7072
+ const requested = [];
7073
+ const sentAt = new Date().toISOString();
7074
+ for (const r of procs) {
7075
+ try { kill(r.pid, signal); requested.push({ pid: r.pid, pgid: r.pgid, comm: r.comm, signal }); }
7076
+ catch (e) { if (e.code !== "ESRCH") throw oatsError("E_SESSION_STOP_FAILED", `could not signal ${r.comm} (pid ${r.pid}): ${e.message}`); requested.push({ pid: r.pid, pgid: r.pgid, comm: r.comm, signal, note: "already gone" }); }
7077
+ }
7078
+ const started = Date.now();
7079
+ const alive = (pid) => { try { kill(pid, 0); return true; } catch (e) { return e.code === "EPERM"; } };
7080
+ let st = before;
7081
+ while (Date.now() - started <= graceMs) {
7082
+ wait(250);
7083
+ try { st = inspectSessionTarget(target, io); } catch { st = { present: true, state: "unknown" }; }
7084
+ const gone = requested.every((r) => !alive(r.pid));
7085
+ if (gone && (!st.present || st.state === "shell" || st.state === "stopped")) return { requested, signal, sentAt, observedAt: new Date().toISOString(), exited: true, waitedMs: Date.now() - started, state: st.state };
7086
+ }
7087
+ return { requested, signal, sentAt, observedAt: null, exited: false, waitedMs: Date.now() - started, state: st.state, stillRunning: requested.filter((r) => alive(r.pid)).map((r) => r.pid) };
7088
+ }
7089
+
7090
+ /** A recipe from a home that predates recipes: only the kernel's own
7091
+ * generated shapes are recognized (identity env, the binary, the runtime's
7092
+ * template flags, --model, yolo, the task prompt); other environment is
7093
+ * kept with provenance "legacy-command"; any other argument is reported as
7094
+ * unclassified and the caller refuses unless a provider can prepare the
7095
+ * launch anew. Never a general shell interpretation. */
7096
+ export function recipeFromLegacyCommand(meta, home) {
7097
+ const { tokens, binary } = parseLaunchCommand(meta.command);
7098
+ // Which capability owned an environment name at spawn: the home's recorded
7099
+ // capability declarations (environment names and namespaces), never a
7100
+ // re-run of a spawn hook.
7101
+ const declared = Array.isArray(meta.capabilityRuntime) ? meta.capabilityRuntime : [];
7102
+ const ownerOf = (name) => declared.find((c) => (c.environment || []).includes(name) || (c.environmentNamespaces || []).some((ns) => name.startsWith(ns)));
7103
+ const runtime = meta.runtime;
7104
+ if (!LAUNCH_RUNTIMES.includes(runtime)) throw oatsError("E_LAUNCH_COMMAND_UNSUPPORTED", `instance records runtime ${JSON.stringify(runtime)}, which this kernel cannot relaunch`);
7105
+ const env = {}, hookEnv = {}, envNames = [];
7106
+ for (const t of tokens.slice(0, binary)) {
7107
+ if (t.kind === "envref") { env[t.name] = { fromEnv: t.source }; continue; }
7108
+ if (IDENTITY_LAUNCH_ENV.has(t.name)) continue;
7109
+ hookEnv[t.name] = t.value; envNames.push(t.name);
7110
+ }
7111
+ const words = tokens.slice(binary + 1);
7112
+ const value = (i) => words[i]?.kind === "word" ? words[i].value : undefined;
7113
+ let i = 0, model = null, yolo, sawPrompt = false;
7114
+ const extras = [];
7115
+ const expect = (...seq) => { for (const w of seq) { if (value(i) !== w) throw oatsError("E_LAUNCH_LEGACY", `the recorded ${runtime} command is not the kernel's generated shape (expected ${JSON.stringify(w)} at argument ${i + 1}); it cannot be converted`); i++; } };
7116
+ if (runtime === "pi") {
7117
+ expect("--no-skills", "--skill", join(home, ".agents", "skills"), "--no-context-files", "--no-prompt-templates", "--append-system-prompt", join(home, "AGENTS.md"), "--approve", "--name", meta.instance);
7118
+ if (value(i) === "--model") { model = value(i + 1) ?? null; i += 2; }
7119
+ expect("@TASK.md"); sawPrompt = true;
7120
+ } else {
7121
+ if (runtime === "codex") {
7122
+ expect("--cd", home);
7123
+ if (value(i) === "--yolo") { yolo = true; i++; if (value(i) === "-c" && /^projects=\{/.test(value(i + 1) || "")) i += 2; }
7124
+ } else if (value(i) === "--dangerously-skip-permissions") { yolo = true; i++; }
7125
+ if (value(i) === "--model") { model = value(i + 1) ?? null; i += 2; }
7126
+ }
7127
+ for (; i < words.length; i++) {
7128
+ const t = words[i];
7129
+ if (t.kind === "sep") { const p = words[i + 1]; if (p?.kind === "prompt" && i + 2 === words.length) { sawPrompt = true; i = words.length; break; } }
7130
+ if (t.kind === "prompt" && runtime !== "pi") { sawPrompt = true; continue; }
7131
+ extras.push(t.kind === "sep" ? "--" : t.value ?? t.text);
7132
+ }
7133
+ if (!sawPrompt) throw oatsError("E_LAUNCH_LEGACY", `the recorded ${runtime} command carries no task prompt in the kernel's shape; it cannot be converted`);
7134
+ const recipe = {
7135
+ version: LAUNCH_RECIPE_VERSION, runtime, launchConfig: null, launchConfigSource: null,
7136
+ executable: tokens[binary].value, executableDeclared: null, executableResolvedFrom: "recorded",
7137
+ args: [], env, model: model || meta.model || null, ...(yolo !== undefined ? { yolo } : meta.yolo !== undefined ? { yolo: meta.yolo } : {}),
7138
+ hooks: { launch: extras.length ? { [runtime]: extras.map((a) => (a === "--" ? a : shq(a))).join(" ") } : {}, env: hookEnv, contributions: (() => {
7139
+ const rows = [];
7140
+ for (const name of envNames.sort()) {
7141
+ const owner = ownerOf(name);
7142
+ const row = rows.find((r) => r.capability === (owner?.id || null));
7143
+ if (row) { row.env.push(name); continue; }
7144
+ rows.push(owner
7145
+ ? { capability: owner.id, layer: owner.layer || null, level: owner.level || null, settings: { ...(owner.settings || {}) }, trust: { trusted: !!owner.trust?.trusted, integrity: owner.trust?.integrity || null }, launch: {}, env: [name], source: "legacy-command" }
7146
+ : { capability: null, source: "legacy-command", launch: {}, env: [name] });
7147
+ }
7148
+ if (extras.length) rows.push({ capability: null, source: "legacy-command", launch: { [runtime]: extras.map((a) => (a === "--" ? a : shq(a))).join(" ") }, env: [] });
7149
+ return rows;
7150
+ })() },
7151
+ prompt: LAUNCH_PROMPT, legacy: { convertedFrom: "command", unclassified: extras },
7152
+ };
7153
+ return { recipe, extras };
7154
+ }
7155
+ /** Capability contributions for a start of an existing home: the recorded
7156
+ * ones, refreshed by any capability that declares a `launch` hook (asked
7157
+ * for the target runtime; side-effect-free by contract; spawn hooks are
7158
+ * never re-run). A runtime change needs the new runtime's launch arguments
7159
+ * from every capability that contributed runtime-specific ones. */
7160
+ /** The providers captured for a home: every recorded capability binding
7161
+ * (meta.capabilityRuntime) united with every recorded contribution's id,
7162
+ * each with its contribution (if it made one) and its captured settings.
7163
+ * Already-recorded metadata; a provider bound to the scope after the spawn
7164
+ * is not in it. */
7165
+ export function capturedProviders(meta, frozen) {
7166
+ const contributions = (frozen?.hooks?.contributions || []).filter((c) => c.capability);
7167
+ const bindings = Array.isArray(meta?.capabilityRuntime) ? meta.capabilityRuntime : [];
7168
+ const ids = [...new Set([...bindings.map((b) => b.id), ...contributions.map((c) => c.capability)])].filter(Boolean);
7169
+ return ids.map((id) => {
7170
+ const contribution = contributions.find((c) => c.capability === id) || null;
7171
+ const binding = bindings.find((b) => b.id === id) || null;
7172
+ return { id, contribution, binding, settings: contribution?.settings ?? binding?.settings ?? {} };
7173
+ });
7174
+ }
7175
+ export function prepareLaunchHooks({ frozen, runtime, resolvedCfg, home, meta, contextDir, extraEnv = {} }) {
7176
+ const contributions = (frozen.hooks?.contributions || []).map((c) => ({ ...c }));
7177
+ const env = { ...(frozen.hooks?.env || {}) };
7178
+ const refreshed = [];
7179
+ // The providers that took part in this home's launch are the CAPTURED ones
7180
+ // (its recorded contributions). Each is resolved by id through the scope's
7181
+ // current installed manifest and trust, whatever the scope's bindings say
7182
+ // now: a captured provider that is no longer installed, or no longer
7183
+ // trusted, refuses the start; a provider bound to the scope after the
7184
+ // spawn is never adopted. A captured provider that declares a launch hook
7185
+ // prepares the target runtime under its CAPTURED settings.
7186
+ const ctx = contextDir || meta.repo;
7187
+ const withLaunchHook = [];
7188
+ for (const p of capturedProviders(meta, frozen)) {
7189
+ const manifest = ctx ? capabilityManifest(p.id, ctx) : undefined;
7190
+ if (!manifest) throw oatsError("E_LAUNCH_PREPARATION", `${p.id} was part of this home's launch at spawn but is no longer installed in the scope; reinstall it (oats install) or respawn the instance; nothing was stopped`);
7191
+ const trust = capabilityTrust(manifest, ctx);
7192
+ if (!trust.trusted) throw oatsError("E_LAUNCH_PREPARATION", `${p.id} was part of this home's launch at spawn but is no longer trusted in the scope (${trust.reason || "not trusted"}); re-trust it (oats trust) or respawn the instance; nothing was stopped`);
7193
+ const hooks = manifestHookCommands(manifest);
7194
+ if (hooks.launch) withLaunchHook.push({ id: p.id, capability: p.id, manifest, layer: p.contribution?.layer ?? p.binding?.layer ?? manifest.layer ?? null, level: p.contribution?.level ?? p.binding?.level ?? null, settings: p.settings, hooks, trust, environment: [...(manifest.environment || [])], environmentNamespaces: [...(manifest.environmentNamespaces || [])], missingRequires: [] });
7195
+ }
7196
+ if (withLaunchHook.length) {
7197
+ const res = runLifecycleHooks("launch", { home, instance: meta.instance, agentName: meta.agent, contextDir: ctx, rootDir: dirname(dirname(dirname(home))), resolved: { ...(resolvedCfg || {}), capabilities: withLaunchHook }, priorMeta: meta.capabilityMeta || {}, extraEnv: { OATS_RUNTIME: runtime, OATS_PREVIOUS_RUNTIME: frozen.runtime || "", ...extraEnv } });
7198
+ const failed = (res.failures || []).map((f) => `${f.capability}: ${f.message}`);
7199
+ if (failed.length) throw oatsError("E_LAUNCH_PREPARATION", `a capability could not prepare the ${runtime} launch:\n ${failed.join("\n ")}`);
7200
+ // Ownership holds across retained AND refreshed contributions, as the
7201
+ // spawn runner holds it across providers: a refreshed provider may
7202
+ // replace its own previous keys, never a key another provider retains.
7203
+ const refreshedIds = new Set((res.contributions || []).map((c) => c.capability));
7204
+ const retainedOwner = new Map();
7205
+ for (const c of contributions) if (c.capability && !refreshedIds.has(c.capability)) for (const name of c.env || []) retainedOwner.set(name, c.capability);
7206
+ for (const c of res.contributions || []) for (const name of c.env || []) {
7207
+ if (retainedOwner.has(name)) throw oatsError("E_LAUNCH_PREPARATION", `${c.capability}'s launch hook set ${name}, which ${retainedOwner.get(name)} contributed at spawn and retains; one provider owns an environment name; nothing was stopped`);
7208
+ }
7209
+ for (const c of res.contributions || []) {
7210
+ const idx = contributions.findIndex((x) => x.capability === c.capability);
7211
+ // The provider's previous contribution goes whole, its env names
7212
+ // included, before its new (validated) one is merged: an empty answer
7213
+ // is a replacement too.
7214
+ for (const name of contributions[idx]?.env || []) delete env[name];
7215
+ const row = { ...c, source: "launch-hook" };
7216
+ if (idx >= 0) contributions[idx] = row; else contributions.push(row);
7217
+ refreshed.push(c.capability);
7218
+ }
7219
+ Object.assign(env, res.env || {});
7220
+ }
7221
+ const unprepared = contributions.filter((c) => c.capability !== null && !refreshed.includes(c.capability) && c.launch && c.launch[frozen.runtime] !== undefined && c.launch[runtime] === undefined).map((c) => c.capability);
7222
+ const legacyArgs = contributions.filter((c) => c.capability === null && c.launch && Object.keys(c.launch).length);
7223
+ if (legacyArgs.length && !refreshed.length) throw oatsError("E_LAUNCH_LEGACY", `the recorded command carries arguments this kernel cannot attribute (${legacyArgs.map((c) => c.launch[frozen.runtime] || Object.values(c.launch)[0]).join(" ")}); no captured capability declares a launch hook to prepare the launch anew, so the start is refused; respawn the instance, or have the provider declare a launch hook`);
7224
+ if (unprepared.length) throw oatsError("E_LAUNCH_PREPARATION", `${unprepared.join(", ")} contributed ${frozen.runtime} launch arguments at spawn and none for ${runtime}; change that capability's setting (for example its delivery mode), or the provider must declare a launch hook; nothing was stopped`);
7225
+ const launch = {};
7226
+ for (const c of contributions) { if (c.capability === null && refreshed.length) continue; for (const [rt, args] of Object.entries(c.launch || {})) if (args) launch[rt] = `${launch[rt] ? `${launch[rt]} ` : ""}${args}`; }
7227
+ return { launch, env, contributions: contributions.filter((c) => !(c.capability === null && refreshed.length)), refreshed };
7228
+ }
7229
+
6586
7230
  /** Start a stopped instance again in its existing home: no new home, no
6587
7231
  * spawn hooks, no identity work. The persisted launch command runs in the
6588
7232
  * recorded tmux session (on the recorded socket) or Herdr server, the
@@ -6611,6 +7255,12 @@ function writeJsonAtomic(path, value, mode) {
6611
7255
  * gate: a target that is present (or a retained dead pane) is recorded and
6612
7256
  * adopted, an exited target has its metadata reconciled before restarting,
6613
7257
  * and a target that cannot be observed refuses and keeps the receipt. */
7258
+ /** Stop the running harness of an existing home and start it again in place
7259
+ * (a selection may change the configuration, runtime, model or yolo): one
7260
+ * per-home lock and pending receipt across stop and launch, every preflight
7261
+ * before the stop, a bounded SIGTERM with no escalation, factual receipts.
7262
+ * Not a retirement: home, work, identity and notes stay. */
7263
+ export function restartInstanceSession(home, o = {}) { return startInstanceSession(home, { ...o, restart: true }); }
6614
7264
  export function startInstanceSession(home, o = {}) {
6615
7265
  if (typeof home !== "string" || !isAbsolute(home)) throw oatsError("E_BAD_ARGS", "session start needs an absolute instance home");
6616
7266
  const realHome = realPathOrNearest(home);
@@ -6631,7 +7281,7 @@ export function startInstanceSession(home, o = {}) {
6631
7281
  // The independent receipt first (retire and session consult it), then the
6632
7282
  // mutable metadata; both tmp+rename. A failure between them is what the
6633
7283
  // pending receipt exists for.
6634
- const record = (meta, { id, backend, target, model, command, startedAt, reused }, clearPending = true) => {
7284
+ const record = (meta, { id, backend, target, model, command, startedAt, reused, launch, runtime: newRuntime, yolo: newYolo, stop }, clearPending = true) => {
6635
7285
  const baselinePath = retirementBaselinePath(realHome);
6636
7286
  let baseline;
6637
7287
  try { baseline = JSON.parse(readFileSync(baselinePath, "utf8")); } catch (e) { throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", `independent session receipt is missing or unreadable for ${realHome}: ${e.message}`); }
@@ -6640,16 +7290,17 @@ export function startInstanceSession(home, o = {}) {
6640
7290
  ? { launched: true, sessionTarget: target }
6641
7291
  : { launched: true, tmux: { session: target.session, window: target.window, socket: resolve(target.socket) } };
6642
7292
  writeJsonAtomic(baselinePath, baseline, 0o600);
6643
- if (o.io?.failBeforeMetadataWrite) throw new Error("injected metadata write failure");
7293
+ if (o.io?.failBeforeMetadataWrite && reused !== "adopted") throw new Error("injected metadata write failure"); // the write after THIS start's allocation
6644
7294
  const recorded = meta.startId === id;
6645
7295
  const restarts = (Array.isArray(meta.restarts) ? meta.restarts : []).slice(recorded ? -20 : -19);
6646
7296
  if (!recorded) restarts.push({ startedAt, model: model ?? null, reused });
6647
- const next = { ...meta, model, command, launched: true, startId: id, restarts, restartCount: (meta.restartCount || 0) + (recorded ? 0 : 1) };
7297
+ const next = { ...meta, model, command, launched: true, startId: id, restarts, restartCount: (meta.restartCount || 0) + (recorded ? 0 : 1),
7298
+ ...(launch ? { launch } : {}), ...(newRuntime ? { runtime: newRuntime } : {}), ...(newYolo !== undefined ? { yolo: newYolo } : {}) };
6648
7299
  if (backend === "herdr") { next.sessionTarget = target; delete next.tmux; }
6649
7300
  else { next.tmux = { session: target.session, window: target.window, socket: resolve(target.socket) }; delete next.sessionTarget; }
6650
7301
  writeJsonAtomic(metaPath, next);
6651
7302
  if (clearPending) rmSync(pendingPath, { force: true });
6652
- return { instance: meta.instance, agent: meta.agent, home: realHome, runtime: meta.runtime, backend, model: model ?? null, target, startedAt, restartCount: next.restartCount, reused };
7303
+ return { instance: meta.instance, agent: meta.agent, home: realHome, runtime: next.runtime, backend, model: model ?? null, launchConfig: next.launch?.launchConfig ?? null, yolo: next.yolo ?? null, target, startedAt, restartCount: next.restartCount, reused, ...(stop ? { stop } : {}) };
6653
7304
  };
6654
7305
  try { mkdirSync(lock); }
6655
7306
  catch (e) {
@@ -6671,7 +7322,11 @@ export function startInstanceSession(home, o = {}) {
6671
7322
  : pt?.backend === "tmux" && [pt.session, pt.window, pt.socket].every((v) => typeof v === "string" && v.length > 0) && isAbsolute(pt.socket);
6672
7323
  let validCommand = false;
6673
7324
  try { parseLaunchCommand(pending?.command); validCommand = true; } catch { /* preserve invalid receipt below */ }
6674
- const validReceipt = validTarget && validCommand
7325
+ let validLaunch = true;
7326
+ if (pending?.launch !== undefined) { try { assertLaunchRecipe(pending.launch, "the pending start"); } catch { validLaunch = false; } }
7327
+ if (pending?.runtime !== undefined && !LAUNCH_RUNTIMES.includes(pending.runtime)) validLaunch = false;
7328
+ if (pending?.yolo !== undefined && typeof pending.yolo !== "boolean") validLaunch = false;
7329
+ const validReceipt = validTarget && validCommand && validLaunch
6675
7330
  && typeof pending.id === "string" && /^[a-zA-Z0-9-]{1,80}$/.test(pending.id)
6676
7331
  && (pending.model === null || (typeof pending.model === "string" && !!pending.model.trim() && !pending.model.includes("\0")))
6677
7332
  && typeof pending.startedAt === "string" && Number.isFinite(Date.parse(pending.startedAt));
@@ -6695,9 +7350,15 @@ export function startInstanceSession(home, o = {}) {
6695
7350
  const meta = readMeta();
6696
7351
  const done = record(meta, { ...pending, backend: pbackend, model: pending.model ?? undefined, reused: "adopted" }, !st.present || st.state === "shell");
6697
7352
  if (st.present && st.state !== "shell") {
6698
- if (o.model != null && String(o.model).trim() && resolveModelPreference(String(o.model), meta.runtime) !== done.model) throw oatsError("E_SESSION_RUNNING", `${meta.instance} is already running with its previously requested model; its target was recovered, but the new model was not applied`);
6699
- if (meta.startId === pending.id) throw oatsError("E_SESSION_RUNNING", `${meta.instance} is already running; nothing was started`);
6700
- return done;
7353
+ if (o.restart) { rmSync(pendingPath, { force: true }); }
7354
+ else {
7355
+ // The recovered target runs what the receipt says; a choice made
7356
+ // now (model, configuration, runtime, yolo) was not applied to it.
7357
+ if (o.model != null && String(o.model).trim() && resolveModelPreference(String(o.model), done.runtime || meta.runtime) !== done.model) throw oatsError("E_SESSION_RUNNING", `${meta.instance} is already running with its previously requested model; its target was recovered, but the new model was not applied`);
7358
+ if (o.launchConfig !== undefined || o.runtime !== undefined || o.yolo !== undefined) throw oatsError("E_SESSION_RUNNING", `${meta.instance} is already running (its pending start was recovered); the requested launch configuration, runtime or yolo was not applied; stop it, or use session restart`);
7359
+ if (meta.startId === pending.id) throw oatsError("E_SESSION_RUNNING", `${meta.instance} is already running; nothing was started`);
7360
+ return done;
7361
+ }
6701
7362
  }
6702
7363
  }
6703
7364
  // 2. The ordinary gate and observation, all under the lock.
@@ -6708,12 +7369,39 @@ export function startInstanceSession(home, o = {}) {
6708
7369
  const backend = meta.sessionTarget || meta.backend === "herdr" ? "herdr" : "tmux";
6709
7370
  let command = meta.command;
6710
7371
  let model = meta.model || undefined;
6711
- if (o.model !== undefined && o.model !== null && String(o.model).trim() !== "") {
7372
+ // What this start launches: the frozen command (optionally with another
7373
+ // model, re-rendered in place), or, under a selection, the recipe
7374
+ // re-resolved against the home's current scoped configuration. Every
7375
+ // preflight happens here, before anything is observed or stopped.
7376
+ const selected = o.launchConfig !== undefined || o.runtime !== undefined || o.yolo !== undefined;
7377
+ const hasRecipe = meta.launch && typeof meta.launch === "object";
7378
+ let launchPlan = null;
7379
+ if (selected || hasRecipe) {
7380
+ // Every start of a home with a recipe (ordinary, model-only, or under a
7381
+ // selection) goes through the one planner: recipe shape, the recorded
7382
+ // or selected executable, references, capability contributions under
7383
+ // the captured settings and current trust. A home that predates
7384
+ // recipes is converted only when a selection asks for it.
7385
+ const context = meta.repo && existsSync(meta.repo) ? resolve(meta.repo) : dirname(dirname(dirname(dirname(realHome))));
7386
+ const resolvedCfg = resolveOatsConfig(context, meta.agent);
7387
+ let agent; try { agent = findAgent(dirname(dirname(dirname(realHome))), meta.agent); } catch { agent = undefined; }
7388
+ const plan = planLaunch({ home: realHome, instance: meta.instance, meta, contextDir: context, agentLike: agent || { runtime: meta.runtime, model: meta.model, yolo: meta.yolo }, selection: { launchConfig: o.launchConfig, runtime: o.runtime, model: o.model, yolo: o.yolo }, resolvedCfg, env: o.env || process.env });
7389
+ launchPlan = { recipe: plan.recipe, command: plan.command, runtime: plan.runtime, model: plan.model, yolo: plan.yolo };
7390
+ command = launchPlan.command; model = launchPlan.model;
7391
+ } else if (o.model !== undefined && o.model !== null && String(o.model).trim() !== "") {
6712
7392
  const resolved = resolveModelPreference(String(o.model), runtime);
6713
7393
  if (!resolved) throw oatsError("E_MODEL_UNKNOWN", `model preference ${JSON.stringify(o.model)} has no entry usable by runtime ${runtime}; give a ${runtime} model id`);
6714
7394
  command = withLaunchModel(command, resolved);
6715
7395
  model = resolved;
6716
7396
  } else parseLaunchCommand(command);
7397
+ // References recorded for this home must resolve on this host on every
7398
+ // start path, and the source variables go to the pane, not the command.
7399
+ const recipeForEnv = launchPlan?.recipe || (meta.launch && typeof meta.launch === "object" ? meta.launch : null);
7400
+ if (recipeForEnv) { const missing = missingLaunchEnvRefs(recipeForEnv.env, o.env || process.env); if (missing.length) throw oatsError("E_LAUNCH_ENV_MISSING", `this home's launch references ${missing.join(", ")}, not set on this host; nothing was started`); }
7401
+ const paneEnv = recipeForEnv ? launchEnvRefs(recipeForEnv, o.env || process.env) : [];
7402
+ const paneEnvFlags = paneEnv.flatMap((r) => ["-e", `${r.name}=${r.value}`]);
7403
+ const paneEnvExports = paneEnv.map((r) => `export ${r.name}=${shq(r.value)}; `).join("");
7404
+ const planExtra = launchPlan ? { launch: launchPlan.recipe, runtime: launchPlan.runtime, yolo: launchPlan.yolo } : {};
6717
7405
  let target = receipt.target;
6718
7406
  let state = { present: false, state: "not-launched" };
6719
7407
  let serverGone = false;
@@ -6724,7 +7412,18 @@ export function startInstanceSession(home, o = {}) {
6724
7412
  else throw oatsError("E_SESSION_UNKNOWN", `cannot establish whether ${meta.instance} is running, so nothing was started: ${String(e.stderr ?? e.message ?? "").trim() || e.message}`);
6725
7413
  }
6726
7414
  }
6727
- if (state.present && state.state !== "shell") throw oatsError("E_SESSION_RUNNING", `${meta.instance} is running (${state.state}); nothing was started`);
7415
+ let stopReceipt = null;
7416
+ if (state.present && state.state !== "shell") {
7417
+ if (!o.restart) throw oatsError("E_SESSION_RUNNING", `${meta.instance} is running (${state.state}); nothing was started`);
7418
+ // Restart: every preflight above passed, so ask the running harness to
7419
+ // end and wait, bounded. A harness still there afterwards is reported
7420
+ // as running; nothing is escalated and nothing is launched.
7421
+ stopReceipt = stopHarness(target, { graceMs: o.stopGraceMs ?? 20000, io: o.io, kill: o.io?.kill, sleep: o.io?.sleep });
7422
+ writeJsonAtomic(join(realHome, ".oats-restart.json"), { instance: meta.instance, at: new Date().toISOString(), stop: stopReceipt, next: { runtime: launchPlan?.runtime || runtime, launchConfig: launchPlan?.recipe?.launchConfig ?? meta.launch?.launchConfig ?? null, model: model ?? null } }, 0o600);
7423
+ if (!stopReceipt.exited) throw oatsError("E_SESSION_STOP_FAILED", `${meta.instance} was asked to stop (${stopReceipt.signal} to ${stopReceipt.requested.map((r) => `${r.comm} pid ${r.pid}`).join(", ")} at ${stopReceipt.sentAt}) and was still running after ${stopReceipt.waitedMs} ms (${stopReceipt.state}); nothing was escalated and nothing was started; stop it yourself, or retry with a longer --stop-grace. Receipt: ${join(realHome, ".oats-restart.json")}`);
7424
+ try { state = inspectSessionTarget(target, o.io); } catch (e) { if (backend === "tmux" && lostTmuxServer(e)) { serverGone = true; state = { present: false, state: "stopped" }; } else throw oatsError("E_SESSION_UNKNOWN", `after the stop, cannot establish the state of ${meta.instance}: ${String(e.stderr ?? e.message ?? "").trim() || e.message}`); }
7425
+ if (state.present && state.state !== "shell") throw oatsError("E_SESSION_UNKNOWN", `${meta.instance} read as stopped and then as ${state.state} again; nothing was started`);
7426
+ }
6728
7427
  const startedAt = new Date().toISOString();
6729
7428
  const id = randomUUID();
6730
7429
  const completedCommand = `${command}; oats_start_status=$?; printf '%s\\n' ${shq(id)} > ${shq(exitedPath)}`;
@@ -6738,8 +7437,8 @@ export function startInstanceSession(home, o = {}) {
6738
7437
  catch (e) { throw oatsError("E_SESSION_UNKNOWN", `Herdr server on ${base.socket} is not reachable, so nothing was started: ${e.message}`); }
6739
7438
  target = allocateHerdr(base, { home: realHome, instance: meta.instance }, o.io);
6740
7439
  }
6741
- writeJsonAtomic(pendingPath, { id, target, command, model: model ?? null, startedAt }, 0o600);
6742
- try { launchHerdr(target, `cd ${shq(realHome)} && ${completedCommand}; exit "$oats_start_status"`, o.io); }
7440
+ writeJsonAtomic(pendingPath, { id, target, command, model: model ?? null, startedAt, ...planExtra }, 0o600);
7441
+ try { launchHerdr(target, `${paneEnvExports}cd ${shq(realHome)} && ${completedCommand}; exit "$oats_start_status"`, o.io); }
6743
7442
  catch (e) { throw launchFailure("Herdr", e); }
6744
7443
  } else {
6745
7444
  const session = target?.session || meta.tmux?.session || DEFAULT_TMUX_SESSION;
@@ -6750,8 +7449,8 @@ export function startInstanceSession(home, o = {}) {
6750
7449
  // the agent's own pane: the command runs there, no other window touched.
6751
7450
  const inPlace = state.paneId && (state.present || state.state === "stopped");
6752
7451
  if (inPlace) {
6753
- writeJsonAtomic(pendingPath, { id, target, command, model: model ?? null, startedAt }, 0o600);
6754
- try { tmuxOn(socket, ["respawn-pane", "-k", "-t", state.paneId, "-c", realHome, windowCmd], o.io); }
7452
+ writeJsonAtomic(pendingPath, { id, target, command, model: model ?? null, startedAt, ...planExtra }, 0o600);
7453
+ try { tmuxOn(socket, ["respawn-pane", "-k", "-t", state.paneId, "-c", realHome, ...paneEnvFlags, windowCmd], o.io); }
6755
7454
  catch (e) { throw launchFailure("tmux", e); }
6756
7455
  reused = "pane";
6757
7456
  } else {
@@ -6780,8 +7479,8 @@ export function startInstanceSession(home, o = {}) {
6780
7479
  }
6781
7480
  if (names.includes(window)) throw oatsError("E_SESSION_RUNNING", `tmux window ${session}:${window} appeared on ${socket} during the start; nothing was started`);
6782
7481
  target = { backend: "tmux", session, window, socket: resolve(socket) };
6783
- writeJsonAtomic(pendingPath, { id, target, command, model: model ?? null, startedAt }, 0o600);
6784
- try { tmuxOn(socket, ["new-window", "-t", `=${session}:`, "-n", window, "-c", realHome, windowCmd], o.io); }
7482
+ writeJsonAtomic(pendingPath, { id, target, command, model: model ?? null, startedAt, ...planExtra }, 0o600);
7483
+ try { tmuxOn(socket, ["new-window", "-t", `=${session}:`, "-n", window, "-c", realHome, ...paneEnvFlags, windowCmd], o.io); }
6785
7484
  catch (e) { throw launchFailure("tmux", e); }
6786
7485
  }
6787
7486
  target = { backend: "tmux", session, window, socket: resolve(socket) };
@@ -6789,7 +7488,7 @@ export function startInstanceSession(home, o = {}) {
6789
7488
  // Keep launch evidence until the command exits or the target disappears.
6790
7489
  // A transient child (for example cat TASK.md) is not proof that startup
6791
7490
  // has finished. A later start reconciles the receipt without a watcher.
6792
- try { return record(meta, { id, backend, target, model, command, startedAt, reused }, false); }
7491
+ try { return record(meta, { id, backend, target, model, command, startedAt, reused, ...planExtra, ...(stopReceipt ? { stop: stopReceipt } : {}) }, false); }
6793
7492
  catch (e) {
6794
7493
  if (e.code && String(e.code).startsWith("E_")) throw e;
6795
7494
  throw oatsError("E_SESSION_START_INCOMPLETE", `${meta.instance} was started (${backend === "herdr" ? `Herdr pane ${target.paneId}` : `tmux ${target.session}:${target.window} on ${target.socket}`}) but its metadata could not be recorded: ${e.message}; the actual target is kept in ${pendingPath} and the next start adopts it instead of allocating another`);