@awebai/oats 0.22.17 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +7 -2
  2. package/bin/oats.mjs +365 -31
  3. package/docs/configuration.md +65 -0
  4. package/docs/design/2026-09-07-mobile-agent-management-proposal.md +228 -0
  5. package/docs/design/2026-09-08-expert-assisted-deployment-proposal.md +558 -0
  6. package/docs/design/2026-09-13-knowledge-and-memory-direction.md +744 -0
  7. package/docs/design/2026-09-13-knowledge-implementation.md +127 -0
  8. package/docs/design/2026-09-13-knowledge-location-contract.md +340 -0
  9. package/docs/design/launch-configurations.md +164 -0
  10. package/docs/design/package-runtime-api.md +177 -3
  11. package/docs/desktop-cli-api.md +68 -2
  12. package/docs/desktop-instance-start.md +39 -3
  13. package/docs/execution-targets.md +16 -0
  14. package/docs/knowledge-capability-authoring.md +98 -0
  15. package/docs/knowledge-reference/acceptance.md +108 -0
  16. package/docs/knowledge-reference/adoption.md +61 -0
  17. package/docs/knowledge-reference/harvester.md +107 -0
  18. package/docs/knowledge-reference/model.md +84 -0
  19. package/docs/knowledge-reference/package-craft.md +126 -0
  20. package/docs/knowledge-reference/provider-mapping.md +77 -0
  21. package/docs/knowledge-reference/reader-capture.md +87 -0
  22. package/docs/knowledge-theory.md +20 -6
  23. package/docs/layers.md +8 -7
  24. package/docs/oats-config.schema.json +33 -2
  25. package/docs/release-notes/v0.22.18.md +101 -0
  26. package/docs/release-notes/v0.22.19.md +115 -0
  27. package/docs/release-notes/v0.23.0.md +93 -0
  28. package/docs/souls-and-instances.md +18 -1
  29. package/injects/work-directory.md +18 -0
  30. package/lib/core.mjs +1109 -187
  31. package/lib/schedule.mjs +12 -2
  32. package/lib/servers.mjs +89 -4
  33. package/package.json +2 -2
  34. package/packages/record/README.md +19 -0
  35. package/packages/record/bin/capture.mjs +144 -53
  36. package/packages/record/bin/recall.mjs +17 -11
  37. package/packages/record/bin/record-native-start.mjs +11 -0
  38. package/packages/record/lib/capture-cc.mjs +82 -27
  39. package/packages/record/lib/capture-lock.mjs +81 -5
  40. package/packages/record/lib/formats.mjs +108 -21
  41. package/packages/record/lib/native-history.mjs +87 -0
  42. package/packages/record/lib/session-roots.mjs +90 -0
  43. package/packages/record/lib/session-snapshot.mjs +61 -0
  44. package/packages/record/lib/sessions-for-home.mjs +88 -56
  45. package/skills/oats/SKILL.md +3 -1
package/lib/core.mjs CHANGED
@@ -23,7 +23,7 @@
23
23
  * soul.yaml (flat key: value):
24
24
  * name, description, kind (persistent|local), type (optional agent-type/family, targeted by config),
25
25
  * repo (path rel. to workspace or absolute),
26
- * work (worktree|checkout|attached), runtime (pi|claude|codex), model (pi model pattern, optional)
26
+ * work (worktree|checkout|attached|workspace|directory), runtime (pi|claude|codex), model (pi model pattern, optional)
27
27
  * (attached as soul default is for service agents — spawn must supply workDir)
28
28
  */
29
29
  import { execFileSync, execSync, spawn as spawnProcess } from "node:child_process";
@@ -31,17 +31,19 @@ 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";
38
+ import { initializeNativeHistory, prepareNativeStart } from "../packages/record/lib/native-history.mjs";
37
39
  import { attachSessionTarget } from "./session-viewer.mjs";
38
40
  import { inspectSessionTarget, inputSessionTarget } from "./session-input.mjs";
39
- import { ensureHerdr, allocateHerdr, launchHerdr, inspectHerdr, stopHerdr, validHerdrTarget, herdrSnapshot } from "./herdr.mjs";
41
+ import { ensureHerdr, allocateHerdr, launchHerdr, inspectHerdr, stopHerdr, validHerdrTarget, herdrSnapshot, herdrCommand } from "./herdr.mjs";
40
42
 
41
43
  export const RESERVED = new Set(["bin", "local-agents", "tmp-agents"]);
42
44
  /** The work modes spawn accepts — also the enum a quarantine cleanup descriptor
43
45
  * must satisfy, so the retry cannot skip Git cleanup on an unrecognised value. */
44
- export const WORK_MODES = ["worktree", "checkout", "attached", "workspace"];
46
+ export const WORK_MODES = ["worktree", "checkout", "attached", "workspace", "directory"];
45
47
  /** Local (uncommitted) souls dir: <scope>/local-agents, a SIBLING of agents/.
46
48
  * Legacy nested <root>/local-agents and <root>/tmp-agents are still read. */
47
49
  export const LOCAL_AGENTS_DIR = "local-agents";
@@ -210,30 +212,106 @@ export function parseYamlFlat(text) {
210
212
  /** Small dependency-free YAML subset used by oats-config.yaml.
211
213
  * Supports nested maps, namespaced/quoted keys, booleans, numbers, and inline arrays/maps. */
212
214
  function yamlScalar(raw) {
213
- const val = raw.trim().replace(/\s+#.*$/, "").trim();
215
+ const trimmed = raw.trim();
216
+ // A double-quoted scalar is read with JSON's escape rules (what the CLI
217
+ // writes for values with spaces, quotes or metacharacters); a single-quoted
218
+ // one with YAML's doubled-quote rule. A trailing comment never cuts a
219
+ // quoted value. Anything the escape rules refuse falls back to the raw text
220
+ // between the quotes, as before.
221
+ if (trimmed.startsWith('"')) {
222
+ let i = 1, esc = false;
223
+ for (; i < trimmed.length; i++) { const c = trimmed[i]; if (esc) esc = false; else if (c === "\\") esc = true; else if (c === '"') break; }
224
+ if (i < trimmed.length) { const q = trimmed.slice(0, i + 1); try { return JSON.parse(q); } catch { return q.slice(1, -1); } }
225
+ }
226
+ if (trimmed.startsWith("'")) {
227
+ let i = 1, out = "";
228
+ for (; i < trimmed.length; i++) { const c = trimmed[i]; if (c === "'") { if (trimmed[i + 1] === "'") { out += "'"; i++; continue; } break; } out += c; }
229
+ if (i < trimmed.length) return out;
230
+ }
231
+ // Inline collections are split quote- and nesting-aware, so an element
232
+ // may carry commas, "#" or nothing at all; a trailing comment after the
233
+ // closing bracket is dropped, one inside quotes is kept.
234
+ if (trimmed.startsWith("[") || trimmed.startsWith("{")) {
235
+ const close = inlineCollectionEnd(trimmed);
236
+ if (close > 0) {
237
+ const inner = trimmed.slice(1, close);
238
+ const parts = splitInline(inner);
239
+ if (trimmed[0] === "[") return parts.map((v) => yamlScalar(v));
240
+ const out = {};
241
+ for (const part of parts) {
242
+ const i = inlineKeyEnd(part);
243
+ if (i < 0) continue;
244
+ const key = yamlKey(part.slice(0, i).trim().replace(/^["']|["']$/g, ""));
245
+ out[key] = yamlScalar(part.slice(i + 1));
246
+ }
247
+ return out;
248
+ }
249
+ }
250
+ const val = trimmed.replace(/\s+#.*$/, "").trim();
214
251
  if (/^(true|false)$/i.test(val)) return val.toLowerCase() === "true";
215
252
  if (/^(null|~)$/i.test(val)) return null;
216
253
  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
254
  return val.replace(/^["']|["']$/g, "");
231
255
  }
256
+ /** Index of the bracket closing the inline collection that opens `s`, or -1. */
257
+ function inlineCollectionEnd(s) {
258
+ let depth = 0, quote = null;
259
+ for (let i = 0; i < s.length; i++) {
260
+ const c = s[i];
261
+ if (quote) { if (c === "\\" && quote === '"') { i++; continue; } if (c === quote) { if (quote === "'" && s[i + 1] === "'") { i++; continue; } quote = null; } continue; }
262
+ if (c === '"' || c === "'") { quote = c; continue; }
263
+ if (c === "[" || c === "{") depth++;
264
+ else if (c === "]" || c === "}") { depth--; if (depth === 0) return i; }
265
+ }
266
+ return -1;
267
+ }
268
+ /** Top-level comma split of an inline collection body (quotes and nesting respected). */
269
+ function splitInline(inner) {
270
+ const parts = []; let depth = 0, quote = null, cur = "", any = false;
271
+ for (let i = 0; i < inner.length; i++) {
272
+ const c = inner[i];
273
+ 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; }
274
+ if (c === '"' || c === "'") { quote = c; cur += c; any = true; continue; }
275
+ if (c === "[" || c === "{") depth++; else if (c === "]" || c === "}") depth--;
276
+ if (c === "," && depth === 0) { parts.push(cur); cur = ""; any = true; continue; }
277
+ if (!/\s/.test(c)) any = true;
278
+ cur += c;
279
+ }
280
+ if (any || cur.trim()) parts.push(cur);
281
+ return parts.filter((p, i) => !(i === parts.length - 1 && !p.trim() && !quoteOnly(p)));
282
+ }
283
+ const quoteOnly = (p) => /^\s*(""|'')\s*$/.test(p);
284
+ /** The first ":" of an inline map entry outside quotes. */
285
+ function inlineKeyEnd(part) {
286
+ let quote = null;
287
+ for (let i = 0; i < part.length; i++) {
288
+ const c = part[i];
289
+ if (quote) { if (c === quote) quote = null; continue; }
290
+ if (c === '"' || c === "'") { quote = c; continue; }
291
+ if (c === ":") return i;
292
+ }
293
+ return -1;
294
+ }
232
295
  export function parseYamlNested(text) {
233
296
  const root = {};
234
297
  const stack = [{ indent: -1, node: root }];
235
298
  for (const raw of text.split("\n")) {
236
299
  if (!raw.trim() || raw.trim().startsWith("#")) continue;
300
+ // A block sequence item ("- value") belongs to the key that opened the
301
+ // current node; the first item turns that node into an array. Items are
302
+ // scalars only (a "- key: value" item is read as the scalar text).
303
+ const seq = raw.match(/^(\s*)-(?:\s+(.*?))?\s*$/);
304
+ if (seq) {
305
+ const indent = seq[1].length;
306
+ while (stack.length > 1 && indent <= stack[stack.length - 1].indent) stack.pop();
307
+ const top = stack[stack.length - 1];
308
+ if (!Array.isArray(top.node)) {
309
+ if (!top.parent || Object.keys(top.node).length) continue; // not a list position: ignored, as before
310
+ top.node = []; top.parent[top.key] = top.node;
311
+ }
312
+ if (seq[2] !== undefined && seq[2] !== "") top.node.push(yamlScalar(seq[2]));
313
+ continue;
314
+ }
237
315
  const m = raw.match(/^(\s*)((?:["'][^"']+["'])|(?:[^:#][^:]*?)):\s*(.*?)\s*$/);
238
316
  if (!m) continue;
239
317
  const [, ws, rawKey, rawVal] = m;
@@ -241,10 +319,11 @@ export function parseYamlNested(text) {
241
319
  const indent = ws.length;
242
320
  while (stack.length > 1 && indent <= stack[stack.length - 1].indent) stack.pop();
243
321
  const parent = stack[stack.length - 1].node;
322
+ if (Array.isArray(parent)) continue; // a key line inside a sequence is not part of this subset
244
323
  if (rawVal.replace(/\s+#.*$/, "").trim() === "" || rawVal.trim().startsWith("#")) {
245
324
  const child = {};
246
325
  parent[key] = child;
247
- stack.push({ indent, node: child });
326
+ stack.push({ indent, node: child, parent, key });
248
327
  } else parent[key] = yamlScalar(rawVal);
249
328
  }
250
329
  return root;
@@ -262,10 +341,12 @@ export function parseFrontmatter(text) {
262
341
  }
263
342
 
264
343
  // ---------- root discovery ----------
265
- /** Closest agents/ dir walking up from `cwd`. Returns undefined if none. */
344
+ /** Closest agents/local-agents layout walking up from `cwd`; without one,
345
+ * the nearest non-laptop config declares a package-only deployment root. */
266
346
  export function findRoot(cwd = process.cwd()) {
267
347
  if (process.env.PI_AGENTS_ROOT) return resolve(process.env.PI_AGENTS_ROOT);
268
348
  let d = resolve(cwd);
349
+ let configuredRoot; // package-only deployments need no pre-created soul directories
269
350
  while (true) {
270
351
  if (basename(d) === "agents" && lstatSync(d).isDirectory()) return d;
271
352
  if (basename(d) === LOCAL_AGENTS_DIR && lstatSync(d).isDirectory() && basename(dirname(d)) !== "agents") {
@@ -276,8 +357,9 @@ export function findRoot(cwd = process.cwd()) {
276
357
  // A scope with only local agents is fully operable: its canonical agents
277
358
  // root is the (possibly absent) sibling agents/ dir.
278
359
  if (existsSync(join(d, LOCAL_AGENTS_DIR)) && lstatSync(join(d, LOCAL_AGENTS_DIR)).isDirectory()) return candidate;
360
+ if (!configuredRoot && d !== homedir() && existsSync(join(d, "oats-config.yaml"))) configuredRoot = candidate;
279
361
  const parent = dirname(d);
280
- if (parent === d) return undefined;
362
+ if (parent === d) return configuredRoot;
281
363
  d = parent;
282
364
  }
283
365
  }
@@ -408,7 +490,7 @@ export function ensureRoot(cwd) {
408
490
  const root = findRoot(cwd);
409
491
  if (!root) {
410
492
  throw new Error(
411
- `no agents/ or local-agents/ directory found walking up from ${resolve(cwd ?? process.cwd())} — create one (mkdir agents, or \`oats create <name> --local\`) or set PI_AGENTS_ROOT`,
493
+ `no agents/, local-agents/, or deployment oats-config.yaml found walking up from ${resolve(cwd ?? process.cwd())} — create one (mkdir agents, or \`oats create <name> --local\`) or set PI_AGENTS_ROOT`,
412
494
  );
413
495
  }
414
496
  // Deployment root ≠ invocation CWD: homes always land in the primary checkout.
@@ -429,7 +511,71 @@ export const RETIRED_CAPABILITIES = {
429
511
  export function retiredCapabilityReason(id) {
430
512
  return Object.hasOwn(RETIRED_CAPABILITIES, id) ? RETIRED_CAPABILITIES[id] : undefined;
431
513
  }
432
- const CONFIG_KEYS = new Set(["name", "team", "agent-types", "capabilities", "skill-overrides", "agents-md-injection", "oats", "work-modes", "templates", "yolo"]);
514
+ const CONFIG_KEYS = new Set(["name", "team", "agent-types", "capabilities", "skill-overrides", "agents-md-injection", "oats", "work-modes", "templates", "yolo", "launch-configs"]);
515
+
516
+ // ---------- launch configurations ----------
517
+ // A named way to start a harness, independent of any soul: the runtime, an
518
+ // executable (a wrapper, another binary), literal argv, environment (literal
519
+ // values, or references resolved on the execution host at start time), a
520
+ // model and yolo. Declared per scope under `launch-configs:`; the closest
521
+ // scope declaring a NAME provides the whole entry (no merging between
522
+ // scopes). Selected at spawn or session start/restart by name.
523
+ export const LAUNCH_RUNTIMES = ["pi", "claude", "codex"];
524
+ export const LAUNCH_CONFIG_KEYS = new Set(["runtime", "executable", "args", "env", "model", "yolo"]);
525
+ const LAUNCH_CONFIG_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
526
+ const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
527
+ /** Environment the kernel sets for every launch (identity, home, roots) and
528
+ * its reference aliases: a configuration may not name them. */
529
+ 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"]);
530
+ export const LAUNCH_REF_PREFIX = "OATS_LAUNCH_REF_";
531
+ const reservedLaunchEnv = (n) => RESERVED_LAUNCH_ENV.has(n) || n.startsWith(LAUNCH_REF_PREFIX);
532
+ export function validateLaunchConfig(name, entry, where) {
533
+ const bad = (why) => { throw oatsError("E_LAUNCH_CONFIG_INVALID", `launch configuration ${JSON.stringify(name)}${where ? ` in ${where}` : ""} ${why}`); };
534
+ if (typeof name !== "string" || !LAUNCH_CONFIG_NAME.test(name)) bad("has an invalid name (letters, digits, dot, underscore, dash; up to 64 characters)");
535
+ if (name === "none") bad("cannot be named none: that word selects no configuration");
536
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) bad("must be a map");
537
+ 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)`);
538
+ if (!LAUNCH_RUNTIMES.includes(entry.runtime)) bad(`needs runtime: one of ${LAUNCH_RUNTIMES.join(", ")}`);
539
+ const text = (v, what) => { if (typeof v !== "string" || !v.trim() || v.includes("\0")) bad(`${what} must be non-empty text`); };
540
+ if (entry.executable !== undefined) text(entry.executable, "executable");
541
+ if (entry.args !== undefined) {
542
+ if (!Array.isArray(entry.args)) bad("args must be a list of strings");
543
+ for (const a of entry.args) if (typeof a !== "string" || a.includes("\0")) bad("args must be a list of strings");
544
+ }
545
+ if (entry.env !== undefined) {
546
+ 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}");
547
+ for (const [n, v] of Object.entries(entry.env)) {
548
+ if (!ENV_NAME.test(n)) bad(`env name ${JSON.stringify(n)} is not a valid environment variable name`);
549
+ if (reservedLaunchEnv(n)) bad(`env ${n} is set by the kernel for every launch and cannot be overridden`);
550
+ if (typeof v === "string") { if (v.includes("\0")) bad(`env ${n} must be text`); continue; }
551
+ 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}`);
552
+ if (v && typeof v === "object" && v.fromEnv && v.fromEnv.startsWith(LAUNCH_REF_PREFIX)) bad(`env ${n} may not reference a ${LAUNCH_REF_PREFIX}* alias`);
553
+ }
554
+ }
555
+ if (entry.model !== undefined) text(entry.model, "model");
556
+ if (entry.yolo !== undefined && typeof entry.yolo !== "boolean") bad("yolo must be true or false");
557
+ return entry;
558
+ }
559
+ function validateLaunchConfigs(map, file) {
560
+ if (map === undefined) return;
561
+ 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`);
562
+ for (const [name, entry] of Object.entries(map)) validateLaunchConfig(name, entry, file);
563
+ }
564
+ /** The effective launch configurations of a config chain (closest first):
565
+ * the closest scope declaring a name provides the WHOLE entry; farther
566
+ * declarations of the same name are recorded as shadowed. */
567
+ export function launchConfigsOf(chain) {
568
+ // Null-prototype: a configuration may legitimately be named constructor or
569
+ // toString, and membership must never be an inherited property.
570
+ const out = Object.create(null);
571
+ for (const c of chain) {
572
+ for (const [name, entry] of Object.entries(c["launch-configs"] || {})) {
573
+ if (Object.hasOwn(out, name)) { out[name].shadows.push(c._level); continue; }
574
+ 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: [] };
575
+ }
576
+ }
577
+ return out;
578
+ }
433
579
  /** Renamed-key tables are read with OWN-property semantics only: a config key
434
580
  * spelled `constructor`/`toString` inherits a value from `Object.prototype`,
435
581
  * and the plain `TABLE[key]` lookup then reported it as the migration hint —
@@ -471,6 +617,7 @@ function loadLevelConfig(dir) {
471
617
  * config source material and must pass the same shape checks). */
472
618
  export function validateConfigShape(cfg, file) {
473
619
  if (cfg.yolo !== undefined && typeof cfg.yolo !== "boolean") throw new Error(`yolo in ${file} must be true or false`);
620
+ validateLaunchConfigs(cfg["launch-configs"], file);
474
621
  for (const key of Object.keys(cfg)) {
475
622
  if (Object.hasOwn(RENAMED_CONFIG_KEYS, key)) throw new Error(`unsupported oats-config key "${key}" in ${file} — ${RENAMED_CONFIG_KEYS[key]}`);
476
623
  if (!CONFIG_KEYS.has(key)) throw new Error(`unsupported oats-config key in ${file}: ${key}`);
@@ -588,7 +735,7 @@ function manifestRequiredHooks(manifest) {
588
735
  }
589
736
  return out;
590
737
  }
591
- const APPROVED_HOOKS = new Set(["soul-scaffold", "spawn", "retire"]);
738
+ const APPROVED_HOOKS = new Set(["soul-scaffold", "spawn", "retire", "launch"]);
592
739
 
593
740
  /** The declared type (agent family) of a soul, read from its soul.yaml via the agents root. */
594
741
  export function soulTypeOf(contextDir, soulName) {
@@ -748,6 +895,7 @@ export function resolveOatsConfig(contextDir, soulName) {
748
895
  const out = { layers: {}, provenance: {}, layerDisabled: {}, injects: [], capabilities: [], name: chain[0]?.name, chain };
749
896
  const yoloCfg = chain.find((c) => c.yolo !== undefined);
750
897
  if (yoloCfg) out.yolo = yoloCfg.yolo;
898
+ out.launchConfigs = launchConfigsOf(chain);
751
899
  // Closest team: declaration wins; the declaring scope is the deployment/team boundary.
752
900
  const teamCfg = chain.find((c) => c.team);
753
901
  if (teamCfg) out.team = { ...teamCfg.team, scope: teamCfg._level };
@@ -4184,9 +4332,11 @@ export const RUNTIME_PACKAGE_MANAGERS = {
4184
4332
  * roots. `--no-approve` keeps a spawn-time probe from trusting
4185
4333
  * project-local files. Falls back to reading settings when pi cannot be
4186
4334
  * run, which yields presence without a verified directory. */
4187
- list: (env = process.env) => {
4335
+ list: (env = process.env, opts = {}) => {
4188
4336
  try {
4189
- const out = execFileSync("pi", ["list", "--no-approve"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], env, timeout: 30000 });
4337
+ // The SELECTED executable answers (a wrapper or another binary), with
4338
+ // pi's own controlled list subcommand only: never a launch argument.
4339
+ const out = execFileSync(opts.bin || "pi", ["list", "--no-approve"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], env, timeout: 30000 });
4190
4340
  const rows = [];
4191
4341
  // pi dims the path with chalk; strip any escapes before matching.
4192
4342
  const lines = out.replace(/\u001b\[[0-9;]*m/g, "").split("\n");
@@ -4475,8 +4625,8 @@ function validateHookEnvironment(capabilityID, value, owners, declarations) {
4475
4625
  return accepted;
4476
4626
  }
4477
4627
 
4478
- export function runLifecycleHooks(event, { home, instance, agentName, soulDir, contextDir, workspaceDir, rootDir, resolved, priorMeta = {}, extraEnv = {} }) {
4479
- const results = { meta: {}, briefs: [], warnings: [], order: [], launch: {}, env: {}, failures: [] };
4628
+ export function runLifecycleHooks(event, { home, instance, agentName, soulDir, contextDir, workspaceDir, rootDir, resolved, priorMeta = {}, extraEnv = {}, assertRoots }) {
4629
+ const results = { meta: {}, briefs: [], warnings: [], order: [], launch: {}, env: {}, failures: [], contributions: [] };
4480
4630
  const envOwners = new Map();
4481
4631
  const envDeclarations = new Map((resolved.capabilities || []).map((cap) => [cap.id, { names: new Set(cap.environment || []), namespaces: [...(cap.environmentNamespaces || [])] }]));
4482
4632
  const caps = [...(resolved.capabilities || [])];
@@ -4488,6 +4638,7 @@ export function runLifecycleHooks(event, { home, instance, agentName, soulDir, c
4488
4638
  if (cap.executable && !cap.trust?.trusted) results.warnings.push(`${cap.id}: executable surface disabled — ${cap.trust?.reason || "not trusted"}`);
4489
4639
  const cmd = cap.hooks?.[event];
4490
4640
  if (!cmd) continue;
4641
+ assertRoots?.();
4491
4642
  results.order.push(cap.id);
4492
4643
  try {
4493
4644
  const stdout = execSync(cmd, {
@@ -4505,6 +4656,10 @@ export function runLifecycleHooks(event, { home, instance, agentName, soulDir, c
4505
4656
  OATS_SOUL: soulDir || "", OATS_CONTEXT: contextDir, OATS_WORKSPACE: workspaceDir || "", OATS_LEVEL: cap.level || "",
4506
4657
  OATS_TEAM_NAME: resolved.team?.name || "", OATS_TEAM_ID: resolved.team?.id || "", OATS_TEAM_SCOPE: resolved.team?.scope || "",
4507
4658
  ...extraEnv,
4659
+ // Hooks also run through direct core callers (not only bin/oats).
4660
+ // Author this from the running kernel, never PATH, ambient env or
4661
+ // a caller's extraEnv: those may point at a different executable.
4662
+ OATS_CLI_BIN: realpathSync(join(PKG_ROOT, "bin", "oats.mjs")),
4508
4663
  OATS_SETTINGS: JSON.stringify(cap.settings || {}),
4509
4664
  OATS_META: JSON.stringify(priorMeta[cap.id] || {}),
4510
4665
  },
@@ -4516,8 +4671,16 @@ export function runLifecycleHooks(event, { home, instance, agentName, soulDir, c
4516
4671
  if (o.brief) results.briefs.push(`- ${o.brief}`);
4517
4672
  if (o.warning) results.warnings.push(o.warning);
4518
4673
  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}`;
4674
+ // Provenance of what this capability contributed to the launch: which
4675
+ // runtimes it answered launch args for, and which env names, under
4676
+ // which settings and trust. A later start or runtime switch reads this.
4677
+ // A launch hook's run is recorded even when its answer is empty: an
4678
+ // empty answer replaces what the provider contributed before.
4679
+ 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)) {
4680
+ 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() : [] });
4681
+ }
4519
4682
  if (o.env !== undefined) {
4520
- if (event !== "spawn") throw new HookEnvironmentContractError(`${cap.id} hook env is supported only for spawn, not ${event}`);
4683
+ if (event !== "spawn" && event !== "launch") throw new HookEnvironmentContractError(`${cap.id} hook env is supported only for spawn and launch, not ${event}`);
4521
4684
  Object.assign(results.env, validateHookEnvironment(cap.id, o.env, envOwners, envDeclarations));
4522
4685
  }
4523
4686
  } catch (e) {
@@ -4556,6 +4719,8 @@ export function runLifecycleHooks(event, { home, instance, agentName, soulDir, c
4556
4719
  // to DETECT hook failures, not just print them (warnings are advisory).
4557
4720
  const required = (cap.requiredHooks || []).includes(event);
4558
4721
  results.failures.push({ capability: cap.id, event, message: detail, required });
4722
+ } finally {
4723
+ assertRoots?.(); // even a failing hook may have exchanged its cwd
4559
4724
  }
4560
4725
  }
4561
4726
  return results;
@@ -4745,6 +4910,18 @@ export function resolveRepo(root, repo) {
4745
4910
  return abs;
4746
4911
  }
4747
4912
 
4913
+ /** Only explicit directory execution relaxes the Git requirement. `repo` is
4914
+ * still the config/deployment context, never a directory we take ownership of. */
4915
+ function resolveExecutionContext(root, target, work) {
4916
+ if (work !== "directory") return resolveRepo(root, target);
4917
+ if (target !== undefined && (typeof target !== "string" || !target.trim() || target.includes("\0"))) {
4918
+ throw oatsError("E_BAD_ARGS", "directory mode repo must name an existing context directory");
4919
+ }
4920
+ const context = target === undefined ? workspaceOf(root) : resolve(workspaceOf(root), target);
4921
+ if (!existsSync(context) || !statSync(context).isDirectory()) throw oatsError("E_BAD_ARGS", `directory mode context is not a directory: ${context}`);
4922
+ return resolve(context);
4923
+ }
4924
+
4748
4925
  // ---------- OKF (Open Knowledge Format) helpers ----------
4749
4926
  export function todayISO() { return new Date().toISOString().slice(0, 10); }
4750
4927
 
@@ -4842,7 +5019,7 @@ export function writeSoul(root, { name, kind, repo, work, runtime, model, yolo,
4842
5019
  // The committed soul remains canonical and config-independent. Composition happens in instances.
4843
5020
  const claudeMd = join(soulDir, "CLAUDE.md");
4844
5021
  try { lstatSync(claudeMd); } catch { symlinkSync("AGENTS.md", claudeMd); }
4845
- const ctx = repo ? resolveRepo(root, repo) : (defaultRepo(root) || workspaceOf(root));
5022
+ const ctx = work === "directory" ? resolveExecutionContext(root, repo, work) : repo ? resolveRepo(root, repo) : (defaultRepo(root) || workspaceOf(root));
4846
5023
  const resolved = resolveOatsConfig(ctx, name);
4847
5024
  runSoulScaffoldHooks({
4848
5025
  home: soulDir, instance: name, agentName: name, soulDir,
@@ -4866,7 +5043,7 @@ export function createAgent(root, o) {
4866
5043
  const name = slug(o.name);
4867
5044
  if (RESERVED.has(name)) throw new Error(`"${name}" is a reserved name`);
4868
5045
  if (findAgent(root, name)) throw new Error(`agent "${name}" already exists`);
4869
- if (o.repo) resolveRepo(root, o.repo);
5046
+ if (o.repo !== undefined) resolveExecutionContext(root, o.repo, o.work);
4870
5047
  // kind: "local" → a FULL soul (memory, skills, instances) under the scope's
4871
5048
  // local-agents/ — uncommitted by contract; otherwise a committed persistent soul.
4872
5049
  const kind = o.local || o.kind === "local" ? "local" : "persistent";
@@ -5085,13 +5262,16 @@ export const RELATIONS = ["child", "sibling", "parent", "unrelated"];
5085
5262
  * Absence still fails the spawn: "aweb on pi requires the aweb pi package" is a
5086
5263
  * promise the instance's INSTRUCTIONS rely on, so starting without it would
5087
5264
  * leave the agent believing it can be woken by mail when it cannot. */
5088
- function verifyRuntimePackages(runtime, resolved, contextDir) {
5265
+ function verifyRuntimePackages(runtime, resolved, contextDir, { bin, env } = {}) {
5266
+ const probeEnv = env || process.env;
5089
5267
  const found = [];
5090
5268
  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 };
5269
+ // The session launches with the SELECTED executable (a configuration's,
5270
+ // or the context-selected one: oats-claude-config may name
5271
+ // `claude-personal`), so probing another binary would inspect a different
5272
+ // account's packages than the instance will actually use. The probe is the
5273
+ // manager's controlled list subcommand; no launch argument is added to it.
5274
+ const probeOpts = { context: contextDir, ...(bin ? { bin } : runtime === "claude" ? { bin: resolveClaudeBinary(contextDir) } : {}) };
5095
5275
  for (const cap of resolved.capabilities || []) {
5096
5276
  for (const raw of cap.manifest?.requires || []) {
5097
5277
  if (!raw || typeof raw !== "object" || raw.runtime !== runtime) continue;
@@ -5103,7 +5283,7 @@ function verifyRuntimePackages(runtime, resolved, contextDir) {
5103
5283
  const spec = raw.package;
5104
5284
  if (!safeRuntimePackageSpec(spec, runtime)) { problems.push(`${cap.id}: ${runtime} package spec is not a plain source token (${JSON.stringify(spec)})`); continue; }
5105
5285
  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);
5286
+ const status = runtimePackageStatus(runtime, spec, probeEnv, probeOpts);
5107
5287
  const mgr = RUNTIME_PACKAGE_MANAGERS[runtime];
5108
5288
  const stepList = mgr?.steps ? mgr.steps(spec, raw, probeOpts) : [mgr?.argv(spec, raw, probeOpts) || []];
5109
5289
  const direct = stepList.filter((a) => a.length).map((a) => a.join(" ")).join(" && ");
@@ -5151,21 +5331,335 @@ function verifyRuntimePackages(runtime, resolved, contextDir) {
5151
5331
  return found.filter((x) => (seen.has(x.identity) ? false : seen.add(x.identity))).sort((a, b) => a.identity.localeCompare(b.identity));
5152
5332
  }
5153
5333
 
5334
+
5335
+ // ---------- launch recipes ----------
5336
+ // What a harness start is made of, recorded in instance.json (`launch`) so a
5337
+ // later start or restart can re-render it, select another configuration, or
5338
+ // switch runtime without guessing from the command string. The rendered
5339
+ // command (`command`) stays beside it, byte-identical to what spawn rendered
5340
+ // before recipes existed when no configuration is selected.
5341
+ export const LAUNCH_RECIPE_VERSION = 1;
5342
+ const LAUNCH_PROMPT = { kind: "task-file", file: "TASK.md" };
5343
+
5344
+ /** The executable a launch uses: a configuration's declared one (a bare name
5345
+ * on PATH; a path against the declaring scope when relative) or the
5346
+ * runtime's default (claude through oats-claude-config). Never executed. */
5347
+ export function resolveLaunchExecutable({ runtime, declared, declaringDir, contextDir }) {
5348
+ if (declared) {
5349
+ if (declared.includes("/")) {
5350
+ const path = isAbsolute(declared) ? declared : resolve(declaringDir || contextDir, declared);
5351
+ return { path, declared, resolvedFrom: isAbsolute(declared) ? "absolute" : `relative to ${declaringDir || contextDir}`, missing: existsSync(path) ? undefined : `${declared} (${path}) does not exist` };
5352
+ }
5353
+ const found = which(declared);
5354
+ return { path: found || null, declared, resolvedFrom: "PATH", missing: found ? undefined : `${declared} binary not found on PATH` };
5355
+ }
5356
+ const claudeBin = runtime === "claude" ? resolveClaudeBinary(contextDir) : undefined;
5357
+ const name = runtime === "claude" ? claudeBin : runtime;
5358
+ const found = which(name);
5359
+ 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)" : ""}` };
5360
+ }
5361
+ /** null when `path` is a regular executable file; otherwise why not. */
5362
+ export function checkLaunchExecutable(path) {
5363
+ try {
5364
+ const st = statSync(path);
5365
+ if (!st.isFile()) return `${path} is not a regular file`;
5366
+ accessSync(path, fsConstants.X_OK);
5367
+ return null;
5368
+ } catch (e) { return `${path} ${e.code === "ENOENT" ? "does not exist" : e.code === "EACCES" ? "is not executable" : e.message}`; }
5369
+ }
5370
+ /** Source names of {fromEnv} references absent from `env`. */
5371
+ export function missingLaunchEnvRefs(configEnv, env = process.env) {
5372
+ return Object.values(configEnv || {}).filter((v) => v && typeof v === "object" && v.fromEnv && env[v.fromEnv] === undefined).map((v) => v.fromEnv).sort();
5373
+ }
5374
+ /** The selection a start makes: which configuration (explicit name, "none",
5375
+ * a frozen recipe's, or the soul's default), and from it the runtime and
5376
+ * model. A named configuration is a unit: an explicit runtime that disagrees
5377
+ * with it is refused. On an existing home, --runtime alone leaves the old
5378
+ * configuration behind (its executable and args are not carried), and a
5379
+ * model never crosses runtimes: explicit or configured model, else the
5380
+ * frozen model on the same runtime, else the runtime's native default. */
5381
+ export function resolveLaunchSelection({ launchConfigs = {}, agent, frozen, selection = {} }) {
5382
+ const bad = (code, msg) => { throw oatsError(code, msg); };
5383
+ let wanted = selection.launchConfig;
5384
+ let config = null;
5385
+ if (wanted === undefined && frozen && !selection.runtime) {
5386
+ // An ordinary start of an existing home runs what was recorded: its
5387
+ // configuration as captured (executable, args, env, references), whether
5388
+ // or not the scope still declares it that way. Only an explicit name
5389
+ // applies the current definition.
5390
+ // Named or not: the recorded executable (a saved wrapper, say), args and
5391
+ // env are what runs; later edits of the scope never change it.
5392
+ 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 };
5393
+ wanted = "none";
5394
+ }
5395
+ if (wanted === undefined) wanted = frozen ? "none" : (agent?.["launch-config"] || "none");
5396
+ if (wanted !== "none") {
5397
+ 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`);
5398
+ config = launchConfigs[wanted];
5399
+ 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)`);
5400
+ }
5401
+ const runtime = config?.runtime || selection.runtime || (frozen ? frozen.runtime : agent?.runtime || "pi");
5402
+ if (!LAUNCH_RUNTIMES.includes(runtime)) bad("E_UNSUPPORTED_RUNTIME", `unknown runtime "${runtime}" (pi|claude|codex)`);
5403
+ let model, modelSource;
5404
+ const explicit = selection.model !== undefined && selection.model !== null && String(selection.model).trim() !== "";
5405
+ if (explicit) {
5406
+ model = resolveModelPreference(String(selection.model), runtime); modelSource = "explicit";
5407
+ if (!model) bad("E_MODEL_UNKNOWN", `model preference ${JSON.stringify(selection.model)} has no entry usable by runtime ${runtime}; give a ${runtime} model id`);
5408
+ } else if (config?.model) {
5409
+ model = config.frozen ? config.model : resolveModelPreference(config.model, runtime); modelSource = config.frozen ? "recorded" : `launch-config ${config.name}`;
5410
+ if (!model) bad("E_MODEL_UNKNOWN", `launch configuration ${config.name} names model ${JSON.stringify(config.model)}, which has no entry usable by runtime ${runtime}`);
5411
+ } else if (frozen) {
5412
+ if (frozen.runtime === runtime) { model = frozen.model || ""; modelSource = model ? "recorded" : "native default"; }
5413
+ else { model = ""; modelSource = "native default (runtime changed)"; }
5414
+ } else if (runtime !== (agent?.runtime || "pi")) {
5415
+ // A soul's model preference belongs to the soul's runtime; a bare alias
5416
+ // is no proof it fits another one. Nothing is passed across.
5417
+ model = ""; modelSource = "native default (runtime differs from the soul's)";
5418
+ } else { model = resolveModelPreference(agent?.model || "", runtime); modelSource = model ? "soul default" : "native default"; }
5419
+ return { config, runtime, model, modelSource, configuredYolo: config?.yolo };
5420
+ }
5421
+ /** The pane environment carrying each reference's value under a
5422
+ * kernel-owned alias (OATS_LAUNCH_REF_<NAME>), which no configuration can
5423
+ * name: the command says NAME="$OATS_LAUNCH_REF_NAME", so no source
5424
+ * variable is ever named in the command and no assignment in the same
5425
+ * prefix can shadow it (zsh evaluates a prefix's assignments in order).
5426
+ * Rendered as tmux `-e` flags or a shell export prefix. */
5427
+ export function launchEnvRefs(recipe, env = process.env) {
5428
+ const out = [];
5429
+ 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 });
5430
+ return out;
5431
+ }
5432
+ export function launchEnvTmuxFlags(recipe, env) { return launchEnvRefs(recipe, env).map((r) => ` -e ${shq(`${r.name}=${r.value}`)}`).join(""); }
5433
+ export function launchEnvExports(recipe, env) { return launchEnvRefs(recipe, env).map((r) => `export ${r.name}=${shq(r.value)}; `).join(""); }
5434
+
5435
+ /** The harness command line of a recipe. With no configuration the bytes
5436
+ * equal what spawn rendered before recipes: env prefix, the executable, the
5437
+ * runtime's own arguments, capability launch args, the task prompt. A
5438
+ * configuration's args go after the runtime's own options and before
5439
+ * capability args; for claude/codex the `--` separator keeps them from
5440
+ * swallowing the task, for pi they follow the task like capability args.
5441
+ *
5442
+ * pi runs the STRICT CURRICULUM: the OATS-composed skill set and AGENTS.md
5443
+ * only (--no-skills + --skill, --no-context-files, --no-prompt-templates,
5444
+ * --append-system-prompt); extensions stay ambient by founder ruling (no
5445
+ * --no-extensions, no -e), and the task positional goes ahead of contributed
5446
+ * options because pi has no `--`. claude gets `--` before the prompt so a
5447
+ * greedy contributed flag cannot eat it. codex keeps its native policy;
5448
+ * yolo also trusts this generated home for the launch (projects=...). */
5449
+ export function renderLaunchRecipe(recipe, { home, instance, redact = false }) {
5450
+ const { runtime, executable, model, yolo } = recipe;
5451
+ const cfgArgs = (recipe.args || []).map(shq).join(" ");
5452
+ const hookArgs = recipe.hooks?.launch?.[runtime] || "";
5453
+ const tail = `${cfgArgs ? ` ${cfgArgs}` : ""}${hookArgs ? ` ${hookArgs}` : ""}`;
5454
+ let cmdline;
5455
+ if (runtime === "claude") {
5456
+ cmdline = `${shq(executable)}${yolo ? " --dangerously-skip-permissions" : ""}${model ? ` --model ${shq(model)}` : ""}${tail} -- "$(cat TASK.md)"`;
5457
+ } else if (runtime === "codex") {
5458
+ const codexTrust = `projects={${JSON.stringify(realPathOrNearest(home))}={trust_level="trusted"}}`;
5459
+ cmdline = `${shq(executable)} --cd ${shq(home)}${yolo ? ` --yolo -c ${shq(codexTrust)}` : ""}${model ? ` --model ${shq(model)}` : ""}${tail} -- "$(cat TASK.md)"`;
5460
+ } else {
5461
+ 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}`;
5462
+ }
5463
+ const hookEnv = recipe.hooks?.env || {};
5464
+ const envTokens = Object.keys(hookEnv).sort().map((name) => `${name}=${shq(redact ? "<redacted>" : hookEnv[name])}`);
5465
+ for (const name of Object.keys(recipe.env || {}).sort()) {
5466
+ const v = recipe.env[name];
5467
+ envTokens.push(typeof v === "string" ? `${name}=${shq(redact ? "<redacted>" : v)}` : `${name}="$${LAUNCH_REF_PREFIX}${name}"`);
5468
+ }
5469
+ 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}`;
5470
+ }
5471
+
5472
+ /** Execution, not preview: mark pending before dispatch, then resolve native
5473
+ * storage inside the backend shell under the actual command environment.
5474
+ * The original executable/argv is exec'd unchanged after recording succeeds. */
5475
+ function nativeRecordCommand(command, home, runtime) {
5476
+ const { tokens, binary } = parseLaunchCommand(command);
5477
+ const args = tokens.slice(binary + 1).filter(t => t.kind !== "prompt").map(t => t.value ?? t.text);
5478
+ const id = prepareNativeStart(home, runtime);
5479
+ const recorder = join(PKG_ROOT, "packages", "record", "bin", "record-native-start.mjs");
5480
+ const inner = `${shq(process.execPath)} ${shq(recorder)} ${shq(home)} ${shq(id)} ${shq(runtime)} ${shq(JSON.stringify(args))} && exec ${tokens.slice(binary).map(t => t.text).join(" ")}`;
5481
+ return `${tokens.slice(0, binary).map(t => t.text).join(" ")} /bin/sh -c ${shq(inner)}`;
5482
+ }
5483
+
5484
+ /** A recorded recipe this kernel understands, or a refusal before anything
5485
+ * is observed or stopped. */
5486
+ export function assertLaunchRecipe(recipe, what) {
5487
+ 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`); };
5488
+ if (!recipe || typeof recipe !== "object") bad("not an object");
5489
+ if (recipe.version !== LAUNCH_RECIPE_VERSION) bad(`version ${JSON.stringify(recipe.version)}, expected ${LAUNCH_RECIPE_VERSION}`);
5490
+ if (!LAUNCH_RUNTIMES.includes(recipe.runtime)) bad(`runtime ${JSON.stringify(recipe.runtime)}`);
5491
+ if (typeof recipe.executable !== "string" || !recipe.executable) bad("no executable");
5492
+ if (!Array.isArray(recipe.args) || recipe.args.some((a) => typeof a !== "string")) bad("args are not a list of strings");
5493
+ if (!recipe.env || typeof recipe.env !== "object" || Array.isArray(recipe.env)) bad("env is not a map");
5494
+ 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`);
5495
+ if (recipe.model !== null && recipe.model !== undefined && typeof recipe.model !== "string") bad("model is not text");
5496
+ if (recipe.yolo !== undefined && typeof recipe.yolo !== "boolean") bad("yolo is not a boolean");
5497
+ if (!recipe.hooks || typeof recipe.hooks !== "object") bad("no hooks record");
5498
+ return recipe;
5499
+ }
5500
+ /** The runtime-package requirements that apply: declared for this runtime
5501
+ * and, for a conditional row, holding under the provider's (captured)
5502
+ * settings. Nothing else is probed or restricted. */
5503
+ export function applicableRequirements(runtime, providers) {
5504
+ 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)));
5505
+ const out = [];
5506
+ 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 });
5507
+ return out;
5508
+ }
5509
+ function requirementsWithArgsMessage(runtime, providers, config) {
5510
+ const rows = applicableRequirements(runtime, providers);
5511
+ 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`;
5512
+ }
5513
+ /** ONE planner for what a start would run, used by preview and by starts of
5514
+ * existing homes alike: the recorded recipe (or, under a selection, the
5515
+ * current scoped configuration) resolved, preflighted, rendered. `preview`
5516
+ * collects every failed check into `preflight` instead of throwing. A home
5517
+ * that predates recipes is not planned here (E_LAUNCH_LEGACY): its frozen
5518
+ * command is described as is, and converted only by restart. */
5519
+ export function planLaunch({ home, instance, meta, contextDir, agentLike, selection = {}, launchConfigs, resolvedCfg, env = process.env, preview = false, assertRoots }) {
5520
+ assertRoots?.();
5521
+ const problems = [];
5522
+ const fail = (check, code, detail) => { if (!preview) throw oatsError(code, detail); problems.push({ check, ok: false, detail, code }); };
5523
+ // A recorded recipe is validated before anything is observed; a home that
5524
+ // predates recipes is converted narrowly from its recorded command (the
5525
+ // kernel's own generated shapes only; environment attributed through the
5526
+ // home's capability declarations); the conversion is recorded by the start
5527
+ // that uses it.
5528
+ const frozen = meta ? (meta.launch && typeof meta.launch === "object" ? assertLaunchRecipe(meta.launch, meta.instance || home) : recipeFromLegacyCommand(meta, home).recipe) : null;
5529
+ const chosen = resolveLaunchSelection({ launchConfigs: launchConfigs || resolvedCfg?.launchConfigs || {}, agent: agentLike, frozen, selection });
5530
+ const { config, runtime, model, modelSource } = chosen;
5531
+ const yolo = resolveYolo(selection.yolo ?? chosen.configuredYolo ?? (frozen ? frozen.yolo : agentLike?.yolo ?? resolvedCfg?.yolo));
5532
+ const executable = config?.frozen
5533
+ ? { path: config.executablePath, declared: frozen.executableDeclared ?? null, resolvedFrom: frozen.executableResolvedFrom || "recorded", missing: existsSync(config.executablePath) ? undefined : `${config.executablePath} (recorded) does not exist` }
5534
+ : resolveLaunchExecutable({ runtime, declared: config?.executable, declaringDir: config?.source, contextDir });
5535
+ const exeProblem = executable.path ? checkLaunchExecutable(executable.path) : executable.missing;
5536
+ 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})` });
5537
+ // Capability contributions: recorded at spawn with provenance; a runtime
5538
+ // switch needs the new runtime's launch args from the same capabilities.
5539
+ let hooks = { launch: {}, env: {}, contributions: [], pending: true };
5540
+ if (frozen) {
5541
+ // Recorded contributions, refreshed by capabilities that declare a
5542
+ // launch hook; a runtime change needs the new runtime's arguments from
5543
+ // every capability that gave runtime-specific ones; recorded arguments
5544
+ // of a capability the scope no longer trusts are not reused.
5545
+ try {
5546
+ hooks = prepareLaunchHooks({ frozen, runtime, resolvedCfg, home, meta, contextDir, assertRoots });
5547
+ const current = new Map((resolvedCfg?.capabilities || []).map((c) => [c.id, c]));
5548
+ const untrusted = hooks.contributions.filter((c) => c.capability && current.has(c.capability) && !current.get(c.capability).trust?.trusted).map((c) => c.capability);
5549
+ const inactive = hooks.contributions.filter((c) => c.capability && !current.has(c.capability)).map((c) => c.capability);
5550
+ 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`);
5551
+ 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(", ")}` : ""}` });
5552
+ } catch (e) {
5553
+ if (!e.code || !["E_LAUNCH_PREPARATION", "E_LAUNCH_LEGACY"].includes(e.code)) throw e;
5554
+ fail("capabilities", e.code, e.message);
5555
+ hooks = { launch: { ...(frozen.hooks?.launch || {}) }, env: { ...(frozen.hooks?.env || {}) }, contributions: frozen.hooks?.contributions || [] };
5556
+ }
5557
+ } else problems.push({ check: "capabilities", ok: true, detail: "decided by the capabilities' spawn hooks" });
5558
+ // A configuration may not override environment a capability owns.
5559
+ const configEnv = config?.env || {};
5560
+ const owned = Object.keys(configEnv).filter((n) => Object.hasOwn(hooks.env, n));
5561
+ if (owned.length) {
5562
+ const owner = (n) => hooks.contributions.find((c) => (c.env || []).includes(n))?.capability || "a capability";
5563
+ 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`);
5564
+ }
5565
+ const missing = missingLaunchEnvRefs(configEnv, env);
5566
+ 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`);
5567
+ 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` });
5568
+ problems.push({ check: "model", ok: true, detail: model ? `${model} (${modelSource})` : `native default (${modelSource})` });
5569
+ // Runtime package requirements: the captured providers' (bindings united
5570
+ // with contributions, by id, current manifest, captured settings for
5571
+ // conditional rows) for an existing home, the scope's for a new instance;
5572
+ // probed under the launch's EFFECTIVE environment with the selected
5573
+ // executable, only when some requirement is declared for this runtime and
5574
+ // every reference resolved. Configuration arguments cannot be applied to a
5575
+ // package probe (it must never start a conversation), which is stated.
5576
+ if (executable.path && !missing.length && !owned.length) {
5577
+ const providers = frozen
5578
+ ? 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)
5579
+ : (resolvedCfg?.capabilities || []);
5580
+ const declared = applicableRequirements(runtime, providers).length > 0;
5581
+ if (declared && (config?.args || []).length) {
5582
+ // The controlled probe cannot carry configuration arguments, so with
5583
+ // an applicable requirement a launch under such arguments cannot be
5584
+ // reported verified: refused, truthfully, with the way out.
5585
+ fail("runtime-packages", "E_LAUNCH_PROBE_UNSUPPORTED", requirementsWithArgsMessage(runtime, providers, config));
5586
+ } else if (declared) {
5587
+ try {
5588
+ verifyRuntimePackages(runtime, { capabilities: providers }, contextDir, { ...(config?.executable || config?.frozen ? { bin: executable.path } : {}), env: launchEffectiveEnv({ base: env, hooksEnv: hooks.env, configEnv }) });
5589
+ 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" : ""}` });
5590
+ } catch (e) { fail("runtime-packages", "E_RUNTIME_PACKAGE", e.message); }
5591
+ } else problems.push({ check: "runtime-packages", ok: true, detail: "no runtime package requirement declared for this runtime; nothing probed" });
5592
+ }
5593
+ const recipe = {
5594
+ version: LAUNCH_RECIPE_VERSION, runtime, launchConfig: config?.name || null, launchConfigSource: config?.source || null,
5595
+ executable: executable.path || executable.declared || runtime, executableDeclared: executable.declared ?? null, executableResolvedFrom: executable.resolvedFrom,
5596
+ args: [...(config?.args || [])], env: { ...configEnv }, model: model || null, ...(yolo !== undefined ? { yolo } : {}),
5597
+ hooks: frozen ? { launch: hooks.launch, env: hooks.env, contributions: hooks.contributions } : hooks, prompt: LAUNCH_PROMPT,
5598
+ ...(frozen?.legacy ? { legacy: { ...frozen.legacy, ...(hooks.refreshed?.length ? { replacedBy: hooks.refreshed } : {}) } } : {}),
5599
+ };
5600
+ const inst = instance || meta?.instance || basename(home);
5601
+ const command = renderLaunchRecipe(recipe, { home, instance: inst });
5602
+ const selectionSource = frozen ? (config?.frozen || (!config && !selection.launchConfig && !selection.runtime) ? "frozen" : "config") : "config";
5603
+ return { recipe, command, runtime, model: model || undefined, modelSource, yolo, config, executable, preflight: problems, ok: problems.every((c) => c.ok), selectionSource, frozen };
5604
+ }
5605
+ /** The environment a planned launch runs under: the host's base, the
5606
+ * capabilities' validated env, the configuration's literals and its
5607
+ * references resolved from the BASE (never from the prefix beside them).
5608
+ * Package probes run under exactly this, so a CLAUDE_CONFIG_DIR or a
5609
+ * credential reference selects the same account the launch will. */
5610
+ export function launchEffectiveEnv({ base = process.env, hooksEnv = {}, configEnv = {} } = {}) {
5611
+ const out = { ...base, ...hooksEnv };
5612
+ for (const [name, v] of Object.entries(configEnv || {})) {
5613
+ if (typeof v === "string") out[name] = v;
5614
+ else if (v && typeof v === "object" && v.fromEnv && base[v.fromEnv] !== undefined) out[name] = base[v.fromEnv];
5615
+ }
5616
+ return out;
5617
+ }
5618
+ /** argv (after the executable) and environment names of a rendered command,
5619
+ * from the parser: what a GUI shows, never the TASK body. */
5620
+ export function describeLaunchCommand(command) {
5621
+ const { tokens, binary } = parseLaunchCommand(command);
5622
+ 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 });
5623
+ const argv = tokens.slice(binary + 1).map((t) => t.kind === "sep" ? "--" : t.kind === "prompt" ? t.text : t.value);
5624
+ return { executable: tokens[binary].value, argv, environment };
5625
+ }
5626
+ /** A persisted command with every environment value withheld (references
5627
+ * stay references); a command the parser refuses is withheld whole. */
5628
+ /** The identity environment every launch carries: public facts (instance
5629
+ * name, home path), shown in public renderings; everything else is withheld. */
5630
+ export const IDENTITY_LAUNCH_ENV = new Set(["OATS_INSTANCE", "OATS_INSTANCE_HOME", "PI_AGENT_INSTANCE", "PI_AGENT_HOME"]);
5631
+ export function redactLaunchCommand(command) {
5632
+ try { return parseLaunchCommand(command).tokens.map((t) => t.kind === "env" && !IDENTITY_LAUNCH_ENV.has(t.name) ? `${t.name}='<redacted>'` : t.text).join(" "); }
5633
+ catch { return "<unparseable launch command withheld>"; }
5634
+ }
5635
+ /** The recipe with every environment value withheld. */
5636
+ export function redactLaunchRecipe(recipe) {
5637
+ const env = Object.fromEntries(Object.keys(recipe.env || {}).sort().map((n) => [n, typeof recipe.env[n] === "string" ? { redacted: true } : { fromEnv: recipe.env[n].fromEnv }]));
5638
+ const hooks = recipe.hooks ? { ...recipe.hooks, env: Object.fromEntries(Object.keys(recipe.hooks.env || {}).sort().map((n) => [n, { redacted: true }])) } : undefined;
5639
+ return { ...recipe, env, ...(hooks ? { hooks } : {}) };
5640
+ }
5154
5641
  export function spawnInstance(root, agent, o = {}) {
5155
5642
  const work = o.work || agent.work || "checkout";
5156
5643
  if (!WORK_MODES.includes(work)) throw new Error(`unknown work mode "${work}" (${WORK_MODES.join("|")})`);
5644
+ if (work === "directory" && (o.workDir !== undefined || o.branch !== undefined)) {
5645
+ throw oatsError("E_BAD_ARGS", "directory mode owns only <home>/work; workDir/--work-dir and branch/--branch are not allowed");
5646
+ }
5157
5647
  if (work === "attached" && !o.workDir) throw new Error(`attached mode needs workDir — the owning instance's work tree (its <home>/work)`);
5158
5648
  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);
5649
+ 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
5650
  const session = o.tmuxSession || DEFAULT_TMUX_SESSION;
5163
5651
  const backend = o.backend || agent.backend || "tmux";
5164
5652
  if (!["tmux", "herdr"].includes(backend)) throw new Error(`unknown session backend "${backend}" (tmux|herdr)`);
5165
5653
  if (o.herdrSocket !== undefined && (typeof o.herdrSocket !== "string" || !o.herdrSocket)) throw oatsError("E_BAD_ARGS", "herdrSocket must be a socket path");
5166
5654
  const launch = o.launch !== false;
5167
- const repoAbs = resolveRepo(root, o.repo || agent.repo);
5655
+ const repoAbs = resolveExecutionContext(root, work === "directory" ? (o.repo !== undefined ? o.repo : agent.repo) : (o.repo || agent.repo), work);
5168
5656
  if (!repoAbs) throw new Error(`agent "${agent.name}" has no repo configured — pass one`);
5657
+ // Launch selection: a named configuration (explicit, or the soul's
5658
+ // launch-config default), or none; the runtime and model follow from it.
5659
+ const launchSelection = resolveLaunchSelection({ launchConfigs: launchConfigsOf(configChain(repoAbs)), agent, selection: { launchConfig: o.launchConfig, runtime: o.runtime, model: o.model } });
5660
+ const launchConfig = launchSelection.config;
5661
+ const runtime = launchSelection.runtime;
5662
+ const model = launchSelection.model;
5169
5663
  // Instance homes belong in the soul-owning repo's PRIMARY checkout, never in a
5170
5664
  // linked worktree (see canonicalDeploymentPath). The CLI resolves this through
5171
5665
  // ensureRoot, but the kernel is its own validation boundary — the desktop
@@ -5468,7 +5962,7 @@ export function spawnInstance(root, agent, o = {}) {
5468
5962
  const soulDir = agent._soulDir || soulOf(agent._dir);
5469
5963
  const composition = composeInstanceAgentsMd(soulDir, repoAbs, agent.name, work, agent.kind);
5470
5964
  const resolvedCfg = composition.resolved;
5471
- const yolo = resolveYolo(o.yolo ?? agent.yolo ?? resolvedCfg.yolo);
5965
+ const yolo = resolveYolo(o.yolo ?? launchSelection.configuredYolo ?? agent.yolo ?? resolvedCfg.yolo);
5472
5966
  const expectedResources = planInstanceResources({ resolved: resolvedCfg, soulDir, agent, contextDir: repoAbs, composition });
5473
5967
  // Runtime extensions selected by ACTIVE capabilities for THIS instance's
5474
5968
  // runtime. Strict launch disables ambient extension discovery, so each one has
@@ -5476,16 +5970,26 @@ export function spawnInstance(root, agent, o = {}) {
5476
5970
  // must fail here, loudly, rather than produce an instance that silently lost
5477
5971
  // its channel. `--runtime` can override a soul default long after install-time
5478
5972
  // reconciliation, so this spawn-time check is the authoritative one.
5479
- const runtimePackages = verifyRuntimePackages(runtime, resolvedCfg, repoAbs);
5480
5973
 
5481
5974
  // 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)" : ""}`);
5975
+ const executable = resolveLaunchExecutable({ runtime, declared: launchConfig?.executable, declaringDir: launchConfig?.source, contextDir: repoAbs });
5976
+ if (!executable.path) throw new Error(executable.missing);
5977
+ { const bad = checkLaunchExecutable(executable.path); if (bad) throw oatsError("E_LAUNCH_EXECUTABLE", `launch configuration ${launchConfig?.name || "(runtime default)"}: ${bad}`); }
5978
+ const bin = executable.path;
5979
+ const missingRefs = missingLaunchEnvRefs(launchConfig?.env || {}, process.env);
5980
+ if (missingRefs.length) throw oatsError("E_LAUNCH_ENV_MISSING", `launch configuration ${launchConfig.name} references ${missingRefs.join(", ")}, not set in this environment; nothing was created`);
5981
+ // A configuration's executable answers the package probe (without one the
5982
+ // context-selected name does, as before: its remedies name that command),
5983
+ // under the configuration's environment (literals, references resolved
5984
+ // from the base); the capabilities' own env is not known before their
5985
+ // spawn hooks run, which happens after the home exists.
5986
+ if ((launchConfig?.args || []).length && applicableRequirements(runtime, resolvedCfg.capabilities).length) throw oatsError("E_LAUNCH_PROBE_UNSUPPORTED", `${requirementsWithArgsMessage(runtime, resolvedCfg.capabilities, launchConfig)}; nothing was created`);
5987
+ const runtimePackages = verifyRuntimePackages(runtime, resolvedCfg, repoAbs, { ...(launchConfig?.executable ? { bin } : {}), env: launchEffectiveEnv({ base: process.env, configEnv: launchConfig?.env || {} }) });
5485
5988
  if (launch && !which(backend)) throw new Error(`${backend} not installed${backend === "tmux" ? " (brew install tmux)" : " (https://herdr.dev)"}`);
5486
5989
  const herdrBase = launch && backend === "herdr" ? ensureHerdr({ binary: which("herdr"), socket: o.herdrSocket }) : undefined;
5487
5990
  const task = o.task ?? (o.taskFile ? readFileSync(o.taskFile, "utf8") : "");
5488
5991
 
5992
+ if (existsSync(directoryRollbackPath(homeReal))) throw oatsError("E_WORK_INSPECTION_FAILED", `directory cleanup is still owed for ${home}; restore and retire the retained home before reusing its name`);
5489
5993
  mkdirSync(home, { recursive: true });
5490
5994
  // TOCTOU: the placement checks above ran BEFORE composition and the runtime
5491
5995
  // package preflight, both of which shell out — a window in which anything able
@@ -5503,6 +6007,8 @@ export function spawnInstance(root, agent, o = {}) {
5503
6007
  throw oatsError("E_NO_CANONICAL_ROOT", `instance home ${home} was created at ${createdReal}, not at ${expectedHome} — the path changed after it was validated (a swapped instances/ link), so nothing has been written into it and the spawn is aborted`);
5504
6008
  }
5505
6009
 
6010
+ initializeNativeHistory(home);
6011
+
5506
6012
  // Body: the soul is linked for reference, while instructions are a generated instance-local view.
5507
6013
  symlinkSync(soulDir, join(home, "soul"));
5508
6014
  writeFileSync(join(home, "AGENTS.md"), composition.text);
@@ -5628,6 +6134,9 @@ export function spawnInstance(root, agent, o = {}) {
5628
6134
  const note = incomplete.length ? ` — rollback INCOMPLETE — clean up manually: ${incomplete.join("; ")}` : "";
5629
6135
  throw new Error(`git worktree add/canonicalization failed: ${original}${note}`);
5630
6136
  }
6137
+ } else if (work === "directory") {
6138
+ // An owned execution directory, not a link to the source or a fake Git repo.
6139
+ mkdirSync(join(home, "work"));
5631
6140
  } else if (work === "attached") {
5632
6141
  // Attach to ANOTHER instance's work tree (o.workDir): sibling home, shared tree.
5633
6142
  // The tree belongs to its owner — retire never removes it (work/ is a symlink).
@@ -5658,12 +6167,19 @@ export function spawnInstance(root, agent, o = {}) {
5658
6167
  catch (e) { warnings.push(`worktree setup command failed (continuing): ${String(e.message || e).slice(0, 200)}`); }
5659
6168
  }
5660
6169
 
6170
+ // Establish directory ownership while these are still the kernel's own roots.
6171
+ // A hook may replace either path; it must never mint authority for that target.
6172
+ if (work === "directory") {
6173
+ assertDirectoryRoots(home, homeReal);
6174
+ writeRetirementBaseline(home, join(home, "work"), work, wm, resolvedCfg.capabilities, { launched: false });
6175
+ }
6176
+
5661
6177
  // Capability lifecycle hooks (spawn) — the knowledge integration scaffolds instance
5662
6178
  // memory (STATE.md/log.md/notes/ are OKF conventions, not kernel ones); the
5663
6179
  // messaging integration mints the comms identity. Kernel stays memory-agnostic.
5664
6180
  const hookRes = runLifecycleHooks("spawn", {
5665
6181
  home, instance, agentName: agent.name, soulDir, contextDir: repoAbs,
5666
- workspaceDir: workspaceOf(root), resolved: resolvedCfg,
6182
+ workspaceDir: workspaceOf(root), rootDir: root, resolved: resolvedCfg,
5667
6183
  extraEnv: { OATS_TASK: task, OATS_REPO: repoAbs, OATS_BRANCH: branch || "", OATS_WORK: work, OATS_RUNTIME: runtime, OATS_KIND: agent.kind || "persistent" },
5668
6184
  });
5669
6185
  warnings.push(...hookRes.warnings);
@@ -5698,7 +6214,7 @@ export function spawnInstance(root, agent, o = {}) {
5698
6214
  if (work === "worktree") { outstandingGit.add("worktree"); if (branch) outstandingGit.add("branch"); }
5699
6215
  return quarantineInstanceHome({ home, instance, agent, incomplete, failed, outstandingHooks, outstandingGit,
5700
6216
  repoAbs, work, branch, resolvedCfg, hookMeta: hookRes.meta || {},
5701
- launched: true, sessionTarget: spawnHerdr, recordRetirementBaseline: true });
6217
+ launched: true, sessionTarget: spawnHerdr, directoryHome: homeReal, recordRetirementBaseline: true });
5702
6218
  }
5703
6219
  }
5704
6220
  if (windowMayExist && backend === "herdr" && !spawnHerdr) {
@@ -5722,11 +6238,44 @@ export function spawnInstance(root, agent, o = {}) {
5722
6238
  return quarantineInstanceHome({
5723
6239
  home, instance, agent, incomplete, failed, outstandingHooks, outstandingGit,
5724
6240
  repoAbs, work, branch, resolvedCfg, hookMeta: hookRes.meta || {},
5725
- launched: true, tmux: spawnTmux, recordRetirementBaseline: true,
6241
+ launched: true, tmux: spawnTmux, directoryHome: homeReal, recordRetirementBaseline: true,
5726
6242
  });
5727
6243
  }
5728
6244
  }
6245
+ // Once hooks ran, directory execution may already hold authored results.
6246
+ // Preserve before compensation and again afterwards, just like retirement.
6247
+ // Failure to copy retains the home, never converts a failed spawn into loss.
6248
+ const directoryRecoveries = [];
6249
+ let lastDirectoryFingerprint;
5729
6250
  let compensationMeta = {};
6251
+ const retainDirectory = (error) => {
6252
+ incomplete.push(`directory preservation: ${error.message}`);
6253
+ // No successful copy means no destructive cleanup. Even after a partial
6254
+ // compensation pass, retry with the ORIGINAL spawn receipt, not its report.
6255
+ for (const cap of resolvedCfg.capabilities) {
6256
+ if (cap.hooks?.retire || (hookRes.order?.includes(cap.id) && hookRes.meta?.[cap.id])) outstandingHooks.add(cap.id);
6257
+ }
6258
+ const note = quarantineInstanceHome({
6259
+ home, instance, agent, incomplete, failed, outstandingHooks, outstandingGit,
6260
+ repoAbs, work, branch, resolvedCfg, hookMeta: hookRes.meta || {}, compensationMeta,
6261
+ launched: false, directoryPreservation: true, directoryHome: homeReal,
6262
+ recordRetirementBaseline: true,
6263
+ });
6264
+ return `${note}${directoryRecoveries.length ? `; prior work recovery: ${directoryRecoveries.join(", ")}` : ""}`;
6265
+ };
6266
+ const preserveDirectory = () => {
6267
+ if (work !== "directory") return;
6268
+ assertDirectoryRoots(home, homeReal);
6269
+ const path = join(home, "work");
6270
+ if (!readdirSync(path).length) return;
6271
+ const directoryFingerprint = fingerprintTree(path);
6272
+ if (lastDirectoryFingerprint === directoryFingerprint) return;
6273
+ const receipt = preserveRetirementWork({ home, work: path, directory: true, directoryFingerprint, classes: ["directory work bytes"] }, { work, repo: repoAbs }, instance);
6274
+ directoryRecoveries.push(receipt.path);
6275
+ lastDirectoryFingerprint = directoryFingerprint;
6276
+ };
6277
+ try { preserveDirectory(); }
6278
+ catch (e) { return retainDirectory(e); }
5730
6279
  try {
5731
6280
  const comp = runLifecycleHooks("retire", {
5732
6281
  home, instance, agentName: agent.name, soulDir, contextDir: repoAbs,
@@ -5780,6 +6329,8 @@ export function spawnInstance(root, agent, o = {}) {
5780
6329
  incomplete.push(`${cap.id}: its spawn hook reported state it created, but the capability declares no retire hook, so OATS cannot undo it`);
5781
6330
  outstandingHooks.add(cap.id);
5782
6331
  }
6332
+ try { preserveDirectory(); }
6333
+ catch (e) { return retainDirectory(e); }
5783
6334
  let note;
5784
6335
  if (outstandingHooks.size || outstandingGit.size) {
5785
6336
  // Preserve credentials and the original hook receipt until cleanup
@@ -5789,14 +6340,14 @@ export function spawnInstance(root, agent, o = {}) {
5789
6340
  failed,
5790
6341
  outstandingHooks, outstandingGit, repoAbs, work, branch, resolvedCfg,
5791
6342
  hookMeta: hookRes.meta || {}, compensationMeta, launched: false,
5792
- recordRetirementBaseline: true,
6343
+ directoryHome: homeReal, recordRetirementBaseline: true,
5793
6344
  });
5794
6345
  } else {
5795
6346
  try { rmSync(home, { recursive: true, force: true }); } catch (e2) { incomplete.push(`instance home ${home}: ${e2.message}`); }
5796
6347
  if (existsSync(home) && !incomplete.some((m) => m.startsWith("instance home"))) incomplete.push(`instance home ${home}: still present`);
5797
6348
  note = incomplete.length ? ` — rollback INCOMPLETE, clean up manually: ${incomplete.join("; ")}` : " — spawn rolled back";
5798
6349
  }
5799
- return note;
6350
+ return `${note}${directoryRecoveries.length ? `; directory work preserved at ${directoryRecoveries.join(", ")}` : ""}`;
5800
6351
  };
5801
6352
 
5802
6353
  try {
@@ -5805,11 +6356,23 @@ export function spawnInstance(root, agent, o = {}) {
5805
6356
  const code = requiredFailures.some((f) => f.contract === "environment") ? "E_HOOK_ENVIRONMENT_CONTRACT" : "E_REQUIRED_HOOK_FAILED";
5806
6357
  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
6358
  }
6359
+ // Hooks have finished, but no TASK, launch recipe, successful scaffold, or
6360
+ // backend operation may be published until the owned roots are revalidated.
6361
+ if (work === "directory") assertDirectoryRoots(home, homeReal);
6362
+ {
6363
+ const owned = Object.keys(launchConfig?.env || {}).filter((n) => Object.hasOwn(hookRes.env, n));
6364
+ if (owned.length) {
6365
+ const owner = (n) => (hookRes.contributions || []).find((c) => (c.env || []).includes(n))?.capability || "a capability";
6366
+ 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`);
6367
+ }
6368
+ }
5808
6369
  const briefLines = hookRes.briefs.length ? `\n${hookRes.briefs.join("\n")}` : "";
5809
6370
  const workDesc = work === "worktree"
5810
6371
  ? `a dedicated git worktree of ${repoAbs} on branch "${branch}" — commit freely there`
5811
6372
  : work === "attached"
5812
6373
  ? `ATTACHED to another instance's work tree (${o.workDir}, branch ${branch}) — you share it with that instance; make your changes and commits focused, and never switch branches`
6374
+ : work === "directory"
6375
+ ? `an instance-owned execution directory — not a Git worktree or a link to ${repoAbs}; that path supplies configuration only`
5813
6376
  : work === "workspace"
5814
6377
  ? `the WHOLE WORKSPACE (${realpathSync(join(home, "work"))}) — every member repo is read-context; you coordinate, you do not edit member repos (see your work-mode briefing)`
5815
6378
  : `a symlink to the ${repoAbs} checkout — you share it; work on the currently checked-out branch (${branch}) and do not switch branches without being asked`;
@@ -5821,84 +6384,20 @@ You are instance "${instance}" of agent "${agent.name}".
5821
6384
  - 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
6385
  ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spawn time — await instructions.\n"}`);
5823
6386
 
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}`;
6387
+ // Launch command. Spawn IS session start: the recipe is persisted in
6388
+ // instance.json beside its rendering, which is executed in the instance's
6389
+ // tmux window. Capabilities contributed runtime-specific arguments and
6390
+ // environment through their spawn hook; both are recorded with provenance.
6391
+ const recipe = {
6392
+ version: LAUNCH_RECIPE_VERSION, runtime,
6393
+ launchConfig: launchConfig?.name || null, launchConfigSource: launchConfig?.source || null,
6394
+ executable: bin, executableDeclared: executable.declared, executableResolvedFrom: executable.resolvedFrom,
6395
+ args: [...(launchConfig?.args || [])], env: { ...(launchConfig?.env || {}) },
6396
+ model: model || null, ...(yolo !== undefined ? { yolo } : {}),
6397
+ hooks: { launch: { ...hookRes.launch }, env: { ...hookRes.env }, contributions: hookRes.contributions || [] },
6398
+ prompt: LAUNCH_PROMPT,
6399
+ };
6400
+ const cmdline = renderLaunchRecipe(recipe, { home, instance });
5902
6401
 
5903
6402
  const meta = {
5904
6403
  agent: agent.name, kind: agent.kind || "persistent", instance, home,
@@ -5965,19 +6464,22 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
5965
6464
  executable: cap.executable,
5966
6465
  })),
5967
6466
  ...(backend === "herdr" ? { backend } : { tmux: { session, window: instance } }),
5968
- command: cmdline, createdAt: new Date().toISOString(),
6467
+ launch: recipe, command: cmdline, createdAt: new Date().toISOString(),
5969
6468
  };
5970
6469
  const spawnWarnings = warnings;
5971
6470
 
5972
6471
  spawnTmux = meta.tmux;
6472
+ if (work === "directory") assertDirectoryRoots(home, homeReal);
6473
+ const executionCommand = launch ? nativeRecordCommand(cmdline, home, runtime) : null;
5973
6474
  if (launch && backend === "herdr") {
5974
6475
  windowMayExist = true;
5975
6476
  spawnHerdr = allocateHerdr(herdrBase, { home, instance });
5976
6477
  meta.sessionTarget = spawnHerdr;
5977
6478
  meta.launched = true;
5978
6479
  writeFileSync(join(home, "instance.json"), JSON.stringify(meta, null, 2) + "\n");
5979
- writeRetirementBaseline(home, join(home, "work"), work === "worktree", wm, resolvedCfg.capabilities, { launched: true, sessionTarget: spawnHerdr });
5980
- launchHerdr(spawnHerdr, cmdline);
6480
+ writeRetirementBaseline(home, join(home, "work"), work, wm, resolvedCfg.capabilities, { launched: true, sessionTarget: spawnHerdr });
6481
+ try { launchHerdr(spawnHerdr, `${launchEnvExports(recipe, process.env)}${executionCommand}`); }
6482
+ 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
6483
  } else if (launch) {
5982
6484
  if (!tmuxAlive(session)) {
5983
6485
  const hq = existsSync(root) ? root : workspaceOf(root); // all-local scopes may have no agents/ dir
@@ -5991,16 +6493,17 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
5991
6493
  // Commit the final child metadata and its independent byte authority before
5992
6494
  // the managed runtime can write. No child-home transition follows launch.
5993
6495
  writeFileSync(join(home, "instance.json"), JSON.stringify(meta, null, 2) + "\n");
5994
- writeRetirementBaseline(home, join(home, "work"), work === "worktree", wm, resolvedCfg.capabilities, { launched: true, tmux: meta.tmux });
6496
+ writeRetirementBaseline(home, join(home, "work"), work, wm, resolvedCfg.capabilities, { launched: true, tmux: meta.tmux });
5995
6497
  // Wrap the command so the window drops into an interactive shell when the
5996
6498
  // agent exits (e.g. Ctrl-C) instead of tmux killing the window.
5997
- const windowCmd = `${cmdline}; exec "\${SHELL:-/bin/zsh}"`;
6499
+ const windowCmd = `${executionCommand}; exec "\${SHELL:-/bin/zsh}"`;
5998
6500
  windowMayExist = true;
5999
- sh(`tmux new-window -t ${shq(session)} -n ${shq(instance)} -c ${shq(home)} ${shq(windowCmd)}`);
6501
+ try { sh(`tmux new-window -t ${shq(session)} -n ${shq(instance)} -c ${shq(home)}${launchEnvTmuxFlags(recipe, process.env)} ${shq(windowCmd)}`); }
6502
+ 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
6503
  } else {
6001
6504
  meta.launched = false;
6002
6505
  writeFileSync(join(home, "instance.json"), JSON.stringify(meta, null, 2) + "\n");
6003
- writeRetirementBaseline(home, join(home, "work"), work === "worktree", wm, resolvedCfg.capabilities, { launched: false, tmux: meta.tmux });
6506
+ writeRetirementBaseline(home, join(home, "work"), work, wm, resolvedCfg.capabilities, { launched: false, tmux: meta.tmux });
6004
6507
  }
6005
6508
 
6006
6509
  // parent relation: re-point the ANCHOR's recorded lineage so its parent is
@@ -6032,7 +6535,7 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
6032
6535
  }
6033
6536
  }
6034
6537
 
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 };
6538
+ 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
6539
  } catch (error) {
6037
6540
  const note = compensateSpawn();
6038
6541
  error.message += note;
@@ -6057,7 +6560,8 @@ export function listInstances(root, tmuxSession = DEFAULT_TMUX_SESSION) {
6057
6560
  : { instance: e.name, home };
6058
6561
  // A home retained by an incomplete rollback is NOT a live instance: it
6059
6562
  // is preserved state awaiting cleanup, and must read that way.
6060
- const quarantine = join(home, ".oats-rollback-incomplete.json");
6563
+ const fallback = directoryRollbackPath(home);
6564
+ const quarantine = existsSync(fallback) ? fallback : join(home, ".oats-rollback-incomplete.json");
6061
6565
  let rollbackIncomplete;
6062
6566
  if (existsSync(quarantine)) {
6063
6567
  try { rollbackIncomplete = JSON.parse(readFileSync(quarantine, "utf8")); }
@@ -6077,7 +6581,7 @@ export function listInstances(root, tmuxSession = DEFAULT_TMUX_SESSION) {
6077
6581
  try { const state = inspectHerdr({ ...meta.sessionTarget, binary: "herdr" }); liveness = { running: state.present, runtimeState: state.status }; }
6078
6582
  catch (error) { liveness = { running: null, runtimeState: "unreachable", runtimeError: error.message }; }
6079
6583
  }
6080
- return { ...meta, ...liveness, ...(rollbackIncomplete ? { rollbackIncomplete } : {}), ...(retirePending ? { retirePending } : {}) };
6584
+ 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
6585
 
6082
6586
  });
6083
6587
  };
@@ -6192,11 +6696,63 @@ export const QUARANTINE_CLEANUP_VERSION = 1;
6192
6696
  /** The rollback-owned Git steps a quarantine can still owe. */
6193
6697
  export const QUARANTINE_GIT_DEBT = ["worktree", "branch"];
6194
6698
 
6699
+ // A substituted/missing home cannot hold its own receipt. This sibling fallback
6700
+ // is independent of both home bytes and the recovery storage that may have failed.
6701
+ function directoryRollbackPath(home) {
6702
+ return join(dirname(home), `.oats-directory-rollback-${basename(home)}.json`);
6703
+ }
6704
+
6705
+ function assertDirectoryHome(home, canonicalHome = realPathOrNearest(home)) {
6706
+ let st;
6707
+ try { st = lstatSync(home); } catch { /* fail closed below */ }
6708
+ if (!st?.isDirectory() || st.isSymbolicLink() || realpathSync(home) !== canonicalHome) {
6709
+ throw oatsError("E_WORK_INSPECTION_FAILED", `directory instance home was removed or exchanged: ${home}; restore the owned home before retrying cleanup`);
6710
+ }
6711
+ }
6712
+
6713
+ function assertDirectoryRoots(home, canonicalHome) {
6714
+ assertDirectoryHome(home, canonicalHome);
6715
+ const work = join(home, "work");
6716
+ let st;
6717
+ try { st = lstatSync(work); } catch { /* fail closed below */ }
6718
+ if (!st?.isDirectory() || st.isSymbolicLink()) {
6719
+ throw oatsError("E_WORK_INSPECTION_FAILED", `directory work must remain an owned directory, not missing, a link or another filesystem type: ${work}; restore the owned work root before retrying cleanup`);
6720
+ }
6721
+ }
6722
+
6723
+ function directoryIdentity(path) {
6724
+ const st = lstatSync(path);
6725
+ return { dev: st.dev, ino: st.ino };
6726
+ }
6727
+
6728
+ // Read independently of mutable instance bytes, BEFORE realpath(home), hooks,
6729
+ // lock creation or backend observation. A metadata mode edit cannot disable it.
6730
+ function sessionDirectoryGuard(home) {
6731
+ let baseline;
6732
+ try { baseline = JSON.parse(readFileSync(retirementBaselinePath(home), "utf8")); }
6733
+ catch (e) { if (e.code === "ENOENT") return () => {}; throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", "independent session receipt is unreadable"); }
6734
+ const expected = join(realPathOrNearest(dirname(home)), basename(home));
6735
+ const check = () => {
6736
+ if (baseline.directoryWork === true) {
6737
+ if (baseline.version !== RETIRE_BASELINE_VERSION || baseline.home !== expected) throw oatsError("E_WORK_INSPECTION_FAILED", "invalid independent directory home authority");
6738
+ assertDirectoryRoots(home, expected);
6739
+ for (const [name, path] of [["home", home], ["work", join(home, "work")]]) {
6740
+ const actual = directoryIdentity(path), recorded = baseline.directoryRoots?.[name];
6741
+ if (!recorded || actual.dev !== recorded.dev || actual.ino !== recorded.ino) throw oatsError("E_WORK_INSPECTION_FAILED", `directory ${name} was exchanged; restore the owned root before retrying start`);
6742
+ }
6743
+ }
6744
+ let meta;
6745
+ try { meta = JSON.parse(readFileSync(join(home, "instance.json"), "utf8")); } catch { return; } // ordinary receipt validation reports this
6746
+ if ((meta.work === "directory") !== (baseline.directoryWork === true)) throw oatsError("E_WORK_INSPECTION_FAILED", "directory work mode disagrees with independent session authority");
6747
+ };
6748
+ check();
6749
+ return check;
6750
+ }
6751
+
6195
6752
  /** Retain the home and its cleanup receipt when spawn compensation or retirement
6196
6753
  * cannot finish. Keeping the original credentials makes cleanup retryable. */
6197
- function quarantineInstanceHome({ home, instance, agent, incomplete, failed, outstandingHooks, outstandingGit, repoAbs, work, branch, resolvedCfg, hookMeta, compensationMeta, launched, tmux, sessionTarget, recordRetirementBaseline = false, reason }) {
6198
- try {
6199
- writeFileSync(join(home, ".oats-rollback-incomplete.json"), JSON.stringify({
6754
+ function quarantineInstanceHome({ home, instance, agent, incomplete, failed, outstandingHooks, outstandingGit, repoAbs, work, branch, resolvedCfg, hookMeta, compensationMeta, launched, tmux, sessionTarget, recordRetirementBaseline = false, reason, directoryPreservation = false, directoryHome = realPathOrNearest(home) }) {
6755
+ const marker = {
6200
6756
  // `reason` is optional and DEFAULTS to the spawn wording, so every existing
6201
6757
  // caller is byte-identical; only a caller that supplies one differs. The
6202
6758
  // quarantine shape is now reached from two events and a fixed "spawn"
@@ -6211,7 +6767,7 @@ function quarantineInstanceHome({ home, instance, agent, incomplete, failed, out
6211
6767
  version: QUARANTINE_CLEANUP_VERSION,
6212
6768
  repo: repoAbs, work, branch, launched, tmux,
6213
6769
  ...(sessionTarget ? { sessionTarget } : {}),
6214
- outstanding: { hooks: [...outstandingHooks], git: [...outstandingGit] },
6770
+ outstanding: { hooks: [...outstandingHooks], git: [...outstandingGit], ...(directoryPreservation ? { directory: true } : {}) },
6215
6771
  capabilityRuntime: (resolvedCfg.capabilities || []).map((cap) => ({
6216
6772
  id: cap.id, layer: cap.layer, level: cap.level, settings: cap.settings,
6217
6773
  hooks: cap.hooks, requiredHooks: cap.requiredHooks, environment: cap.environment, environmentNamespaces: cap.environmentNamespaces,
@@ -6221,11 +6777,30 @@ function quarantineInstanceHome({ home, instance, agent, incomplete, failed, out
6221
6777
  capabilityMeta: hookMeta || {},
6222
6778
  },
6223
6779
  createdAt: new Date().toISOString(),
6224
- }, null, 2) + "\n");
6225
- } catch { /* the quarantine still stands without its marker */ }
6780
+ };
6781
+ try {
6782
+ const path = join(home, ".oats-rollback-incomplete.json");
6783
+ if (work === "directory") {
6784
+ assertDirectoryHome(home, directoryHome);
6785
+ writeJsonAtomic(path, marker, 0o600);
6786
+ } else writeFileSync(path, JSON.stringify(marker, null, 2) + "\n");
6787
+ } catch {
6788
+ if (work === "directory") {
6789
+ // Never write through a substituted home (even its metadata paths).
6790
+ // The original parent is outside the disposable home; no recovery copy is
6791
+ // needed to retain the frozen hook inputs and the cleanup obligations.
6792
+ try {
6793
+ const path = directoryRollbackPath(directoryHome);
6794
+ if (realpathSync(dirname(path)) !== dirname(path)) throw new Error("directory cleanup parent was redirected; refusing to write through it");
6795
+ writeJsonAtomic(path, marker, 0o600);
6796
+ incomplete.push(`cleanup descriptor retained at ${path}; restore the home before retrying`);
6797
+ } catch (fallbackError) { incomplete.push(`cleanup descriptor could not be stored: ${fallbackError.message}`); }
6798
+ }
6799
+ }
6226
6800
  if (recordRetirementBaseline) {
6227
6801
  try {
6228
- writeRetirementBaseline(home, join(home, "work"), work === "worktree", resolveWorkMode(repoAbs, work), resolvedCfg.capabilities || [], { launched: launched === true, tmux, sessionTarget });
6802
+ if (work === "directory") assertDirectoryRoots(home, directoryHome);
6803
+ writeRetirementBaseline(home, join(home, "work"), work, resolveWorkMode(repoAbs, work), resolvedCfg.capabilities || [], { launched: launched === true, tmux, sessionTarget });
6229
6804
  } catch (e) {
6230
6805
  incomplete.push(`independent retirement authority: ${e.message}`);
6231
6806
  }
@@ -6261,7 +6836,9 @@ function usableCleanupDescriptor(marker) {
6261
6836
  if (c.work === "worktree" && !nonEmptyString(c.branch)) return false;
6262
6837
  if (c.branch !== undefined && !nonEmptyString(c.branch)) return false;
6263
6838
  if (c.capabilityMeta !== undefined && !isPlainObject(c.capabilityMeta)) return false;
6264
- if (!Array.isArray(c.capabilityRuntime) || !c.capabilityRuntime.length) return false;
6839
+ const directoryDebt = c.outstanding?.directory === true && c.work === "directory";
6840
+ if (c.outstanding?.directory !== undefined && !directoryDebt) return false;
6841
+ if (!Array.isArray(c.capabilityRuntime) || (!c.capabilityRuntime.length && !directoryDebt)) return false;
6265
6842
  if (!c.capabilityRuntime.every((cap) => isPlainObject(cap) && nonEmptyString(cap.id))) return false;
6266
6843
  if (!isPlainObject(c.outstanding) || !Array.isArray(c.outstanding.hooks) || !Array.isArray(c.outstanding.git)) return false;
6267
6844
  if (!c.outstanding.hooks.every(nonEmptyString)) return false;
@@ -6270,10 +6847,10 @@ function usableCleanupDescriptor(marker) {
6270
6847
  // other work mode describes a quarantine that could not have happened.
6271
6848
  if (c.outstanding.git.length && c.work !== "worktree") return false;
6272
6849
  // The decisive invariant: a quarantine with NOTHING outstanding is a proof
6273
- // obligation of zero — the retry would run, prove nothing, and delete the home
6274
- // and its credential (reviewer-2baa631). The producer cannot emit it, so a
6275
- // marker claiming it is not one of ours.
6276
- if (!c.outstanding.hooks.length && !c.outstanding.git.length) return false;
6850
+ // obligation of zero. Directory preservation is also real debt: the retry's
6851
+ // independent-authority inspection and verified snapshots must succeed even
6852
+ // when no capability has a retire hook. It is never a Git/shared-work escape.
6853
+ if (!c.outstanding.hooks.length && !c.outstanding.git.length && !directoryDebt) return false;
6277
6854
  // The retry must be ABLE to rerun what it must prove: an outstanding hook whose
6278
6855
  // capability is not in the set could never run, so the quarantine would never
6279
6856
  // clear — and the home would be unremovable without --force.
@@ -6292,7 +6869,7 @@ function retirementStateRoot(home) {
6292
6869
  }
6293
6870
 
6294
6871
  function retirementKey(home) {
6295
- return createHash("sha256").update(realPathOrNearest(home)).digest("hex");
6872
+ return createHash("sha256").update(join(realPathOrNearest(dirname(home)), basename(home))).digest("hex");
6296
6873
  }
6297
6874
 
6298
6875
  function retirementBaselinePath(home) {
@@ -6376,11 +6953,14 @@ function retirementDisposableRoots(work, workMode, capabilities) {
6376
6953
  return roots;
6377
6954
  }
6378
6955
 
6379
- function writeRetirementBaseline(home, work, isWorktree, workMode, capabilities, runtime) {
6956
+ function writeRetirementBaseline(home, work, mode, workMode, capabilities, runtime) {
6957
+ if (mode === "directory") assertDirectoryRoots(home);
6958
+ const isWorktree = mode === "worktree";
6380
6959
  const status = isWorktree && existsSync(work) ? worktreeStatus(work) : "";
6381
6960
  const disposableReceipts = isWorktree ? retirementDisposableRoots(work, workMode, capabilities) : [];
6382
6961
  const baseline = {
6383
6962
  version: RETIRE_BASELINE_VERSION,
6963
+ ...(mode === "directory" ? { directoryWork: true, directoryRoots: { home: directoryIdentity(home), work: directoryIdentity(work) } } : {}),
6384
6964
  home: realPathOrNearest(home),
6385
6965
  homeFingerprint: fingerprintTree(home, { excludeRoot: new Set(["work"]) }),
6386
6966
  disposableReceipts,
@@ -6512,6 +7092,10 @@ export function parseLaunchCommand(command) {
6512
7092
  tokens.push({ kind: "prompt", text: LAUNCH_PROMPT_TOKEN });
6513
7093
  continue;
6514
7094
  }
7095
+ // NAME="$SOURCE": an environment reference, resolved on the execution
7096
+ // host when the command runs; the persisted command carries no value.
7097
+ const ref = /^([A-Za-z_][A-Za-z0-9_]*)="\$([A-Za-z_][A-Za-z0-9_]*)"(?= |$)/.exec(command.slice(i));
7098
+ if (ref) { tokens.push({ kind: "envref", name: ref[1], source: ref[2], text: ref[0] }); i += ref[0].length; continue; }
6515
7099
  let envName;
6516
7100
  const m = /^([A-Za-z_][A-Za-z0-9_]*)='/.exec(command.slice(i));
6517
7101
  if (m) { envName = m[1]; i += envName.length + 1; }
@@ -6541,7 +7125,7 @@ export function parseLaunchCommand(command) {
6541
7125
  }
6542
7126
  let binary = -1;
6543
7127
  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; }
7128
+ if (tokens[k].kind === "env" || tokens[k].kind === "envref") { if (binary >= 0) bad(0, "env assignment after the binary"); continue; }
6545
7129
  if (binary < 0) { if (tokens[k].kind !== "word" || !tokens[k].quoted) bad(0, "no quoted binary after the env prefix"); binary = k; }
6546
7130
  }
6547
7131
  if (binary < 0) bad(0, "no binary");
@@ -6583,6 +7167,237 @@ function writeJsonAtomic(path, value, mode) {
6583
7167
  renameSync(tmp, path);
6584
7168
  }
6585
7169
 
7170
+
7171
+ // ---------- stopping a harness, selecting on an existing home ----------
7172
+ const SHELL_NAMES = new Set(["sh", "bash", "zsh", "fish", "dash", "ksh", "tcsh", "csh", "login"]);
7173
+ /** The harness processes under a session target: for tmux EVERY descendant
7174
+ * of the pane's launcher process (the launcher shell itself is left so
7175
+ * that, once its command ends, it becomes the fallback shell the start
7176
+ * path recognizes); a wrapper that does not exec the harness, the harness
7177
+ * and their children are all included, topmost first. For Herdr the
7178
+ * pane's foreground processes. Each row: { pid, ppid, pgid, comm, depth }. */
7179
+ export function harnessProcesses(target, io) {
7180
+ const ps = () => ((io?.exec || execFileSync)("ps", ["-axo", "pid=,ppid=,pgid=,comm="], { encoding: "utf8", timeout: 10000, maxBuffer: 4 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"] }))
7181
+ .split("\n").map((l) => l.trim().match(/^(\d+)\s+(\d+)\s+(\d+)\s+(.+)$/)).filter(Boolean)
7182
+ .map(([, pid, ppid, pgid, comm]) => ({ pid: Number(pid), ppid: Number(ppid), pgid: Number(pgid), comm: basename(comm).replace(/^-/, "") }));
7183
+ if (target.backend === "herdr") {
7184
+ // Herdr's PaneProcessInfo (installed schema: shell_pid, nullable;
7185
+ // foreground_process_group_id; foreground_processes) names the pane's
7186
+ // shell. That pid, verified against this host, is the root: OATS launched
7187
+ // `exec /bin/sh -c <command>` in it, so the root is the launcher shell
7188
+ // and the harness its child, exactly as under tmux; a root whose exec
7189
+ // replaced it with a non-shell IS the harness and is included. Without a
7190
+ // shell_pid, the verified non-shell foreground processes are the roots.
7191
+ // Never a fabricated row: an unverifiable pid refuses before any signal.
7192
+ const info = herdrCommand(target, ["pane", "process-info", "--pane", target.paneId], io).process_info;
7193
+ if (!info || typeof info !== "object") throw oatsError("E_SESSION_UNKNOWN", "Herdr returned no process information for the pane");
7194
+ const rows = ps();
7195
+ const byParent = new Map();
7196
+ for (const r of rows) { if (!byParent.has(r.ppid)) byParent.set(r.ppid, []); byParent.get(r.ppid).push(r); }
7197
+ const out = [];
7198
+ 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); } } };
7199
+ const verified = (pid, what) => {
7200
+ if (!Number.isInteger(pid) || pid <= 0) throw oatsError("E_SESSION_UNKNOWN", `Herdr reported ${what} without a verifiable pid; nothing was signalled`);
7201
+ const row = rows.find((r) => r.pid === pid);
7202
+ if (!row) throw oatsError("E_SESSION_UNKNOWN", `Herdr reports ${what} pid ${pid} but this host has no such process; nothing was signalled`);
7203
+ return row;
7204
+ };
7205
+ if (info.shell_pid !== null && info.shell_pid !== undefined) {
7206
+ const root = verified(info.shell_pid, "the pane shell");
7207
+ if (!SHELL_NAMES.has(root.comm)) out.push({ ...root, depth: 0 });
7208
+ walk(root.pid, 1);
7209
+ return out;
7210
+ }
7211
+ if (!Array.isArray(info.foreground_processes)) throw oatsError("E_SESSION_UNKNOWN", "Herdr returned neither a pane shell pid nor foreground processes");
7212
+ for (const p of info.foreground_processes) {
7213
+ const row = verified(p.pid, `foreground process ${p.name || "?"}`);
7214
+ if (SHELL_NAMES.has(row.comm)) { walk(row.pid, 1); continue; }
7215
+ if (!out.some((o) => o.pid === row.pid)) { out.push({ ...row, depth: 0 }); walk(row.pid, 1); }
7216
+ }
7217
+ return out;
7218
+ }
7219
+ const row = tmuxOn(target.socket, ["list-panes", "-t", `=${target.session}:=${target.window}`, "-F", "#{pane_pid}"], io).trim().split("\n")[0];
7220
+ if (!/^\d+$/.test(row || "")) throw oatsError("E_SESSION_UNKNOWN", "tmux returned no pane process id");
7221
+ const panePid = Number(row);
7222
+ const rows = ps();
7223
+ const byParent = new Map();
7224
+ for (const r of rows) { if (!byParent.has(r.ppid)) byParent.set(r.ppid, []); byParent.get(r.ppid).push(r); }
7225
+ const out = [];
7226
+ const walk = (pid, depth) => { for (const child of byParent.get(pid) || []) { out.push({ ...child, depth }); walk(child.pid, depth + 1); } };
7227
+ walk(panePid, 1);
7228
+ return out;
7229
+ }
7230
+ /** Ask the harness under `target` to end: SIGTERM to every process found
7231
+ * under the pane's launcher, one by one, topmost first (no process-group
7232
+ * signalling: the launcher shell must survive to become the fallback
7233
+ * shell), then a bounded wait for the signalled processes to be gone and
7234
+ * the session to read as stopped or a bare shell. Nothing is escalated: a
7235
+ * harness still there when the wait ends is reported as such, still
7236
+ * running. Elapsed time is never taken as exit. */
7237
+ export function stopHarness(target, { graceMs = 20000, signal = "SIGTERM", io = {}, kill = process.kill, sleep } = {}) {
7238
+ const wait = sleep || ((ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms));
7239
+ const before = inspectSessionTarget(target, io);
7240
+ 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" };
7241
+ const procs = harnessProcesses(target, io);
7242
+ 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`);
7243
+ const requested = [];
7244
+ const sentAt = new Date().toISOString();
7245
+ for (const r of procs) {
7246
+ try { kill(r.pid, signal); requested.push({ pid: r.pid, pgid: r.pgid, comm: r.comm, signal }); }
7247
+ 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" }); }
7248
+ }
7249
+ const started = Date.now();
7250
+ const alive = (pid) => { try { kill(pid, 0); return true; } catch (e) { return e.code === "EPERM"; } };
7251
+ let st = before;
7252
+ while (Date.now() - started <= graceMs) {
7253
+ wait(250);
7254
+ try { st = inspectSessionTarget(target, io); } catch { st = { present: true, state: "unknown" }; }
7255
+ const gone = requested.every((r) => !alive(r.pid));
7256
+ 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 };
7257
+ }
7258
+ 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) };
7259
+ }
7260
+
7261
+ /** A recipe from a home that predates recipes: only the kernel's own
7262
+ * generated shapes are recognized (identity env, the binary, the runtime's
7263
+ * template flags, --model, yolo, the task prompt); other environment is
7264
+ * kept with provenance "legacy-command"; any other argument is reported as
7265
+ * unclassified and the caller refuses unless a provider can prepare the
7266
+ * launch anew. Never a general shell interpretation. */
7267
+ export function recipeFromLegacyCommand(meta, home) {
7268
+ const { tokens, binary } = parseLaunchCommand(meta.command);
7269
+ // Which capability owned an environment name at spawn: the home's recorded
7270
+ // capability declarations (environment names and namespaces), never a
7271
+ // re-run of a spawn hook.
7272
+ const declared = Array.isArray(meta.capabilityRuntime) ? meta.capabilityRuntime : [];
7273
+ const ownerOf = (name) => declared.find((c) => (c.environment || []).includes(name) || (c.environmentNamespaces || []).some((ns) => name.startsWith(ns)));
7274
+ const runtime = meta.runtime;
7275
+ if (!LAUNCH_RUNTIMES.includes(runtime)) throw oatsError("E_LAUNCH_COMMAND_UNSUPPORTED", `instance records runtime ${JSON.stringify(runtime)}, which this kernel cannot relaunch`);
7276
+ const env = {}, hookEnv = {}, envNames = [];
7277
+ for (const t of tokens.slice(0, binary)) {
7278
+ if (t.kind === "envref") { env[t.name] = { fromEnv: t.source }; continue; }
7279
+ if (IDENTITY_LAUNCH_ENV.has(t.name)) continue;
7280
+ hookEnv[t.name] = t.value; envNames.push(t.name);
7281
+ }
7282
+ const words = tokens.slice(binary + 1);
7283
+ const value = (i) => words[i]?.kind === "word" ? words[i].value : undefined;
7284
+ let i = 0, model = null, yolo, sawPrompt = false;
7285
+ const extras = [];
7286
+ 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++; } };
7287
+ if (runtime === "pi") {
7288
+ 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);
7289
+ if (value(i) === "--model") { model = value(i + 1) ?? null; i += 2; }
7290
+ expect("@TASK.md"); sawPrompt = true;
7291
+ } else {
7292
+ if (runtime === "codex") {
7293
+ expect("--cd", home);
7294
+ if (value(i) === "--yolo") { yolo = true; i++; if (value(i) === "-c" && /^projects=\{/.test(value(i + 1) || "")) i += 2; }
7295
+ } else if (value(i) === "--dangerously-skip-permissions") { yolo = true; i++; }
7296
+ if (value(i) === "--model") { model = value(i + 1) ?? null; i += 2; }
7297
+ }
7298
+ for (; i < words.length; i++) {
7299
+ const t = words[i];
7300
+ if (t.kind === "sep") { const p = words[i + 1]; if (p?.kind === "prompt" && i + 2 === words.length) { sawPrompt = true; i = words.length; break; } }
7301
+ if (t.kind === "prompt" && runtime !== "pi") { sawPrompt = true; continue; }
7302
+ extras.push(t.kind === "sep" ? "--" : t.value ?? t.text);
7303
+ }
7304
+ if (!sawPrompt) throw oatsError("E_LAUNCH_LEGACY", `the recorded ${runtime} command carries no task prompt in the kernel's shape; it cannot be converted`);
7305
+ const recipe = {
7306
+ version: LAUNCH_RECIPE_VERSION, runtime, launchConfig: null, launchConfigSource: null,
7307
+ executable: tokens[binary].value, executableDeclared: null, executableResolvedFrom: "recorded",
7308
+ args: [], env, model: model || meta.model || null, ...(yolo !== undefined ? { yolo } : meta.yolo !== undefined ? { yolo: meta.yolo } : {}),
7309
+ hooks: { launch: extras.length ? { [runtime]: extras.map((a) => (a === "--" ? a : shq(a))).join(" ") } : {}, env: hookEnv, contributions: (() => {
7310
+ const rows = [];
7311
+ for (const name of envNames.sort()) {
7312
+ const owner = ownerOf(name);
7313
+ const row = rows.find((r) => r.capability === (owner?.id || null));
7314
+ if (row) { row.env.push(name); continue; }
7315
+ rows.push(owner
7316
+ ? { 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" }
7317
+ : { capability: null, source: "legacy-command", launch: {}, env: [name] });
7318
+ }
7319
+ if (extras.length) rows.push({ capability: null, source: "legacy-command", launch: { [runtime]: extras.map((a) => (a === "--" ? a : shq(a))).join(" ") }, env: [] });
7320
+ return rows;
7321
+ })() },
7322
+ prompt: LAUNCH_PROMPT, legacy: { convertedFrom: "command", unclassified: extras },
7323
+ };
7324
+ return { recipe, extras };
7325
+ }
7326
+ /** Capability contributions for a start of an existing home: the recorded
7327
+ * ones, refreshed by any capability that declares a `launch` hook (asked
7328
+ * for the target runtime; side-effect-free by contract; spawn hooks are
7329
+ * never re-run). A runtime change needs the new runtime's launch arguments
7330
+ * from every capability that contributed runtime-specific ones. */
7331
+ /** The providers captured for a home: every recorded capability binding
7332
+ * (meta.capabilityRuntime) united with every recorded contribution's id,
7333
+ * each with its contribution (if it made one) and its captured settings.
7334
+ * Already-recorded metadata; a provider bound to the scope after the spawn
7335
+ * is not in it. */
7336
+ export function capturedProviders(meta, frozen) {
7337
+ const contributions = (frozen?.hooks?.contributions || []).filter((c) => c.capability);
7338
+ const bindings = Array.isArray(meta?.capabilityRuntime) ? meta.capabilityRuntime : [];
7339
+ const ids = [...new Set([...bindings.map((b) => b.id), ...contributions.map((c) => c.capability)])].filter(Boolean);
7340
+ return ids.map((id) => {
7341
+ const contribution = contributions.find((c) => c.capability === id) || null;
7342
+ const binding = bindings.find((b) => b.id === id) || null;
7343
+ return { id, contribution, binding, settings: contribution?.settings ?? binding?.settings ?? {} };
7344
+ });
7345
+ }
7346
+ export function prepareLaunchHooks({ frozen, runtime, resolvedCfg, home, meta, contextDir, extraEnv = {}, assertRoots }) {
7347
+ const contributions = (frozen.hooks?.contributions || []).map((c) => ({ ...c }));
7348
+ const env = { ...(frozen.hooks?.env || {}) };
7349
+ const refreshed = [];
7350
+ // The providers that took part in this home's launch are the CAPTURED ones
7351
+ // (its recorded contributions). Each is resolved by id through the scope's
7352
+ // current installed manifest and trust, whatever the scope's bindings say
7353
+ // now: a captured provider that is no longer installed, or no longer
7354
+ // trusted, refuses the start; a provider bound to the scope after the
7355
+ // spawn is never adopted. A captured provider that declares a launch hook
7356
+ // prepares the target runtime under its CAPTURED settings.
7357
+ const ctx = contextDir || meta.repo;
7358
+ const withLaunchHook = [];
7359
+ for (const p of capturedProviders(meta, frozen)) {
7360
+ const manifest = ctx ? capabilityManifest(p.id, ctx) : undefined;
7361
+ 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`);
7362
+ const trust = capabilityTrust(manifest, ctx);
7363
+ 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`);
7364
+ const hooks = manifestHookCommands(manifest);
7365
+ 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: [] });
7366
+ }
7367
+ if (withLaunchHook.length) {
7368
+ const res = runLifecycleHooks("launch", { assertRoots, 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 } });
7369
+ const failed = (res.failures || []).map((f) => `${f.capability}: ${f.message}`);
7370
+ if (failed.length) throw oatsError("E_LAUNCH_PREPARATION", `a capability could not prepare the ${runtime} launch:\n ${failed.join("\n ")}`);
7371
+ // Ownership holds across retained AND refreshed contributions, as the
7372
+ // spawn runner holds it across providers: a refreshed provider may
7373
+ // replace its own previous keys, never a key another provider retains.
7374
+ const refreshedIds = new Set((res.contributions || []).map((c) => c.capability));
7375
+ const retainedOwner = new Map();
7376
+ for (const c of contributions) if (c.capability && !refreshedIds.has(c.capability)) for (const name of c.env || []) retainedOwner.set(name, c.capability);
7377
+ for (const c of res.contributions || []) for (const name of c.env || []) {
7378
+ 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`);
7379
+ }
7380
+ for (const c of res.contributions || []) {
7381
+ const idx = contributions.findIndex((x) => x.capability === c.capability);
7382
+ // The provider's previous contribution goes whole, its env names
7383
+ // included, before its new (validated) one is merged: an empty answer
7384
+ // is a replacement too.
7385
+ for (const name of contributions[idx]?.env || []) delete env[name];
7386
+ const row = { ...c, source: "launch-hook" };
7387
+ if (idx >= 0) contributions[idx] = row; else contributions.push(row);
7388
+ refreshed.push(c.capability);
7389
+ }
7390
+ Object.assign(env, res.env || {});
7391
+ }
7392
+ 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);
7393
+ const legacyArgs = contributions.filter((c) => c.capability === null && c.launch && Object.keys(c.launch).length);
7394
+ 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`);
7395
+ 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`);
7396
+ const launch = {};
7397
+ 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}`; }
7398
+ return { launch, env, contributions: contributions.filter((c) => !(c.capability === null && refreshed.length)), refreshed };
7399
+ }
7400
+
6586
7401
  /** Start a stopped instance again in its existing home: no new home, no
6587
7402
  * spawn hooks, no identity work. The persisted launch command runs in the
6588
7403
  * recorded tmux session (on the recorded socket) or Herdr server, the
@@ -6611,9 +7426,23 @@ function writeJsonAtomic(path, value, mode) {
6611
7426
  * gate: a target that is present (or a retained dead pane) is recorded and
6612
7427
  * adopted, an exited target has its metadata reconciled before restarting,
6613
7428
  * and a target that cannot be observed refuses and keeps the receipt. */
7429
+ /** Stop the running harness of an existing home and start it again in place
7430
+ * (a selection may change the configuration, runtime, model or yolo): one
7431
+ * per-home lock and pending receipt across stop and launch, every preflight
7432
+ * before the stop, a bounded SIGTERM with no escalation, factual receipts.
7433
+ * Not a retirement: home, work, identity and notes stay. */
7434
+ export function restartInstanceSession(home, o = {}) { return startInstanceSession(home, { ...o, restart: true }); }
6614
7435
  export function startInstanceSession(home, o = {}) {
6615
7436
  if (typeof home !== "string" || !isAbsolute(home)) throw oatsError("E_BAD_ARGS", "session start needs an absolute instance home");
7437
+ // Check the sibling receipt BEFORE resolving a possibly substituted home.
7438
+ if (existsSync(directoryRollbackPath(home))) throw oatsError("E_INSTANCE_RETIRING", `${home} has retained directory cleanup; restore and retire it before starting anything there`);
7439
+ const checkRoots = sessionDirectoryGuard(home);
6616
7440
  const realHome = realPathOrNearest(home);
7441
+ let originalHomeIdentity;
7442
+ try { originalHomeIdentity = directoryIdentity(realHome); } catch { /* missing home reported below */ }
7443
+ const rawIo = o.io;
7444
+ const guardedExec = (...args) => { checkRoots(); return (rawIo?.exec || execFileSync)(...args); };
7445
+ o = { ...o, io: { ...rawIo, exec: guardedExec, kill: (...args) => { checkRoots(); return (rawIo?.kill || process.kill)(...args); } } };
6617
7446
  const metaPath = join(realHome, "instance.json");
6618
7447
  if (!existsSync(metaPath)) throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", `${realHome} is not an OATS instance home (no instance.json); nothing was started`);
6619
7448
  const lock = join(realHome, ".oats-start.lock");
@@ -6631,7 +7460,8 @@ export function startInstanceSession(home, o = {}) {
6631
7460
  // The independent receipt first (retire and session consult it), then the
6632
7461
  // mutable metadata; both tmp+rename. A failure between them is what the
6633
7462
  // pending receipt exists for.
6634
- const record = (meta, { id, backend, target, model, command, startedAt, reused }, clearPending = true) => {
7463
+ const record = (meta, { id, backend, target, model, command, startedAt, reused, launch, runtime: newRuntime, yolo: newYolo, stop }, clearPending = true) => {
7464
+ checkRoots();
6635
7465
  const baselinePath = retirementBaselinePath(realHome);
6636
7466
  let baseline;
6637
7467
  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 +7470,17 @@ export function startInstanceSession(home, o = {}) {
6640
7470
  ? { launched: true, sessionTarget: target }
6641
7471
  : { launched: true, tmux: { session: target.session, window: target.window, socket: resolve(target.socket) } };
6642
7472
  writeJsonAtomic(baselinePath, baseline, 0o600);
6643
- if (o.io?.failBeforeMetadataWrite) throw new Error("injected metadata write failure");
7473
+ if (o.io?.failBeforeMetadataWrite && reused !== "adopted") throw new Error("injected metadata write failure"); // the write after THIS start's allocation
6644
7474
  const recorded = meta.startId === id;
6645
7475
  const restarts = (Array.isArray(meta.restarts) ? meta.restarts : []).slice(recorded ? -20 : -19);
6646
7476
  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) };
7477
+ const next = { ...meta, model, command, launched: true, startId: id, restarts, restartCount: (meta.restartCount || 0) + (recorded ? 0 : 1),
7478
+ ...(launch ? { launch } : {}), ...(newRuntime ? { runtime: newRuntime } : {}), ...(newYolo !== undefined ? { yolo: newYolo } : {}) };
6648
7479
  if (backend === "herdr") { next.sessionTarget = target; delete next.tmux; }
6649
7480
  else { next.tmux = { session: target.session, window: target.window, socket: resolve(target.socket) }; delete next.sessionTarget; }
6650
7481
  writeJsonAtomic(metaPath, next);
6651
7482
  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 };
7483
+ 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
7484
  };
6654
7485
  try { mkdirSync(lock); }
6655
7486
  catch (e) {
@@ -6671,13 +7502,18 @@ export function startInstanceSession(home, o = {}) {
6671
7502
  : pt?.backend === "tmux" && [pt.session, pt.window, pt.socket].every((v) => typeof v === "string" && v.length > 0) && isAbsolute(pt.socket);
6672
7503
  let validCommand = false;
6673
7504
  try { parseLaunchCommand(pending?.command); validCommand = true; } catch { /* preserve invalid receipt below */ }
6674
- const validReceipt = validTarget && validCommand
7505
+ let validLaunch = true;
7506
+ if (pending?.launch !== undefined) { try { assertLaunchRecipe(pending.launch, "the pending start"); } catch { validLaunch = false; } }
7507
+ if (pending?.runtime !== undefined && !LAUNCH_RUNTIMES.includes(pending.runtime)) validLaunch = false;
7508
+ if (pending?.yolo !== undefined && typeof pending.yolo !== "boolean") validLaunch = false;
7509
+ const validReceipt = validTarget && validCommand && validLaunch
6675
7510
  && typeof pending.id === "string" && /^[a-zA-Z0-9-]{1,80}$/.test(pending.id)
6676
7511
  && (pending.model === null || (typeof pending.model === "string" && !!pending.model.trim() && !pending.model.includes("\0")))
6677
7512
  && typeof pending.startedAt === "string" && Number.isFinite(Date.parse(pending.startedAt));
6678
7513
  if (!validReceipt) throw oatsError("E_SESSION_UNKNOWN", `an earlier start left an unreadable or invalid receipt at ${pendingPath}; inspect it before retrying; nothing was started`);
6679
7514
  const pbackend = pending.target.backend === "herdr" ? "herdr" : "tmux";
6680
7515
  let st;
7516
+ checkRoots();
6681
7517
  try { st = inspectSessionTarget(pending.target, o.io); }
6682
7518
  catch (e) {
6683
7519
  if (pbackend === "tmux" && lostTmuxServer(e)) st = { present: false, state: "stopped" };
@@ -6695,9 +7531,15 @@ export function startInstanceSession(home, o = {}) {
6695
7531
  const meta = readMeta();
6696
7532
  const done = record(meta, { ...pending, backend: pbackend, model: pending.model ?? undefined, reused: "adopted" }, !st.present || st.state === "shell");
6697
7533
  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;
7534
+ if (o.restart) { rmSync(pendingPath, { force: true }); }
7535
+ else {
7536
+ // The recovered target runs what the receipt says; a choice made
7537
+ // now (model, configuration, runtime, yolo) was not applied to it.
7538
+ 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`);
7539
+ 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`);
7540
+ if (meta.startId === pending.id) throw oatsError("E_SESSION_RUNNING", `${meta.instance} is already running; nothing was started`);
7541
+ return done;
7542
+ }
6701
7543
  }
6702
7544
  }
6703
7545
  // 2. The ordinary gate and observation, all under the lock.
@@ -6708,12 +7550,40 @@ export function startInstanceSession(home, o = {}) {
6708
7550
  const backend = meta.sessionTarget || meta.backend === "herdr" ? "herdr" : "tmux";
6709
7551
  let command = meta.command;
6710
7552
  let model = meta.model || undefined;
6711
- if (o.model !== undefined && o.model !== null && String(o.model).trim() !== "") {
7553
+ // What this start launches: the frozen command (optionally with another
7554
+ // model, re-rendered in place), or, under a selection, the recipe
7555
+ // re-resolved against the home's current scoped configuration. Every
7556
+ // preflight happens here, before anything is observed or stopped.
7557
+ const selected = o.launchConfig !== undefined || o.runtime !== undefined || o.yolo !== undefined;
7558
+ const hasRecipe = meta.launch && typeof meta.launch === "object";
7559
+ let launchPlan = null;
7560
+ if (selected || hasRecipe) {
7561
+ // Every start of a home with a recipe (ordinary, model-only, or under a
7562
+ // selection) goes through the one planner: recipe shape, the recorded
7563
+ // or selected executable, references, capability contributions under
7564
+ // the captured settings and current trust. A home that predates
7565
+ // recipes is converted only when a selection asks for it.
7566
+ const context = meta.repo && existsSync(meta.repo) ? resolve(meta.repo) : dirname(dirname(dirname(dirname(realHome))));
7567
+ const resolvedCfg = resolveOatsConfig(context, meta.agent);
7568
+ let agent; try { agent = findAgent(dirname(dirname(dirname(realHome))), meta.agent); } catch { agent = undefined; }
7569
+ 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, assertRoots: checkRoots });
7570
+ launchPlan = { recipe: plan.recipe, command: plan.command, runtime: plan.runtime, model: plan.model, yolo: plan.yolo };
7571
+ command = launchPlan.command; model = launchPlan.model;
7572
+ } else if (o.model !== undefined && o.model !== null && String(o.model).trim() !== "") {
6712
7573
  const resolved = resolveModelPreference(String(o.model), runtime);
6713
7574
  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
7575
  command = withLaunchModel(command, resolved);
6715
7576
  model = resolved;
6716
7577
  } else parseLaunchCommand(command);
7578
+ // References recorded for this home must resolve on this host on every
7579
+ // start path, and the source variables go to the pane, not the command.
7580
+ const recipeForEnv = launchPlan?.recipe || (meta.launch && typeof meta.launch === "object" ? meta.launch : null);
7581
+ 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`); }
7582
+ const paneEnv = recipeForEnv ? launchEnvRefs(recipeForEnv, o.env || process.env) : [];
7583
+ const paneEnvFlags = paneEnv.flatMap((r) => ["-e", `${r.name}=${r.value}`]);
7584
+ const paneEnvExports = paneEnv.map((r) => `export ${r.name}=${shq(r.value)}; `).join("");
7585
+ checkRoots(); // launch hooks/preparation have run; no backend has been observed
7586
+ const planExtra = launchPlan ? { launch: launchPlan.recipe, runtime: launchPlan.runtime, yolo: launchPlan.yolo } : {};
6717
7587
  let target = receipt.target;
6718
7588
  let state = { present: false, state: "not-launched" };
6719
7589
  let serverGone = false;
@@ -6724,10 +7594,23 @@ export function startInstanceSession(home, o = {}) {
6724
7594
  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
7595
  }
6726
7596
  }
6727
- if (state.present && state.state !== "shell") throw oatsError("E_SESSION_RUNNING", `${meta.instance} is running (${state.state}); nothing was started`);
7597
+ let stopReceipt = null;
7598
+ if (state.present && state.state !== "shell") {
7599
+ if (!o.restart) throw oatsError("E_SESSION_RUNNING", `${meta.instance} is running (${state.state}); nothing was started`);
7600
+ // Restart: every preflight above passed, so ask the running harness to
7601
+ // end and wait, bounded. A harness still there afterwards is reported
7602
+ // as running; nothing is escalated and nothing is launched.
7603
+ stopReceipt = stopHarness(target, { graceMs: o.stopGraceMs ?? 20000, io: o.io, kill: o.io?.kill, sleep: o.io?.sleep });
7604
+ 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);
7605
+ 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")}`);
7606
+ 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}`); }
7607
+ 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`);
7608
+ }
6728
7609
  const startedAt = new Date().toISOString();
6729
7610
  const id = randomUUID();
6730
- const completedCommand = `${command}; oats_start_status=$?; printf '%s\\n' ${shq(id)} > ${shq(exitedPath)}`;
7611
+ checkRoots();
7612
+ const executionCommand = nativeRecordCommand(command, realHome, launchPlan?.runtime || runtime);
7613
+ const completedCommand = `${executionCommand}; oats_start_status=$?; printf '%s\\n' ${shq(id)} > ${shq(exitedPath)}`;
6731
7614
  let reused = "new";
6732
7615
  if (backend === "herdr") {
6733
7616
  if (!target) throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", `this never-launched Herdr home has no saved server endpoint; no tmux fallback was started`);
@@ -6738,8 +7621,9 @@ export function startInstanceSession(home, o = {}) {
6738
7621
  catch (e) { throw oatsError("E_SESSION_UNKNOWN", `Herdr server on ${base.socket} is not reachable, so nothing was started: ${e.message}`); }
6739
7622
  target = allocateHerdr(base, { home: realHome, instance: meta.instance }, o.io);
6740
7623
  }
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); }
7624
+ checkRoots();
7625
+ writeJsonAtomic(pendingPath, { id, target, command, model: model ?? null, startedAt, ...planExtra }, 0o600);
7626
+ try { launchHerdr(target, `${paneEnvExports}cd ${shq(realHome)} && ${completedCommand}; exit "$oats_start_status"`, o.io); }
6743
7627
  catch (e) { throw launchFailure("Herdr", e); }
6744
7628
  } else {
6745
7629
  const session = target?.session || meta.tmux?.session || DEFAULT_TMUX_SESSION;
@@ -6750,21 +7634,28 @@ export function startInstanceSession(home, o = {}) {
6750
7634
  // the agent's own pane: the command runs there, no other window touched.
6751
7635
  const inPlace = state.paneId && (state.present || state.state === "stopped");
6752
7636
  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); }
7637
+ checkRoots();
7638
+ writeJsonAtomic(pendingPath, { id, target, command, model: model ?? null, startedAt, ...planExtra }, 0o600);
7639
+ try { tmuxOn(socket, ["respawn-pane", "-k", "-t", state.paneId, "-c", realHome, ...paneEnvFlags, windowCmd], o.io); }
6755
7640
  catch (e) { throw launchFailure("tmux", e); }
6756
7641
  reused = "pane";
6757
7642
  } else {
6758
7643
  const instancesRoot = dirname(realHome);
6759
7644
  const hq = existsSync(dirname(dirname(instancesRoot))) ? dirname(dirname(instancesRoot)) : realHome;
7645
+ checkRoots();
6760
7646
  if (!socket) {
6761
7647
  // Never launched (--no-launch): the default server, as spawn uses.
6762
- if (!tmuxAlive(session)) {
6763
- sh(`tmux new-session -d -s ${shq(session)} -n hq -c ${shq(hq)}`);
6764
- shTry(`tmux set-option -t ${shq(session)} -g window-size latest`);
6765
- shTry(`tmux set-option -t ${shq(session)} -g aggressive-resize on`);
7648
+ const defaultTmux = args => guardedExec("tmux", args, { encoding: "utf8", timeout: 10000, stdio: ["ignore", "pipe", "pipe"] }).trim();
7649
+ let alive = false;
7650
+ try { defaultTmux(["has-session", "-t", session]); alive = true; } catch (e) { checkRoots(); }
7651
+ if (!alive) {
7652
+ defaultTmux(["new-session", "-d", "-s", session, "-n", "hq", "-c", hq]);
7653
+ for (const option of [["window-size", "latest"], ["aggressive-resize", "on"]]) {
7654
+ try { defaultTmux(["set-option", "-t", session, "-g", ...option]); } catch (e) { checkRoots(); }
7655
+ }
6766
7656
  }
6767
- socket = tmuxSocket(session);
7657
+ socket = defaultTmux(["display-message", "-p", "-t", session, "#{socket_path}"]);
7658
+ if (!socket) throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", "tmux did not report its socket");
6768
7659
  } else if (serverGone) {
6769
7660
  // The recorded server is gone (a reboot): the same socket path again.
6770
7661
  mkdirSync(dirname(socket), { recursive: true });
@@ -6780,8 +7671,9 @@ export function startInstanceSession(home, o = {}) {
6780
7671
  }
6781
7672
  if (names.includes(window)) throw oatsError("E_SESSION_RUNNING", `tmux window ${session}:${window} appeared on ${socket} during the start; nothing was started`);
6782
7673
  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); }
7674
+ checkRoots();
7675
+ writeJsonAtomic(pendingPath, { id, target, command, model: model ?? null, startedAt, ...planExtra }, 0o600);
7676
+ try { tmuxOn(socket, ["new-window", "-t", `=${session}:`, "-n", window, "-c", realHome, ...paneEnvFlags, windowCmd], o.io); }
6785
7677
  catch (e) { throw launchFailure("tmux", e); }
6786
7678
  }
6787
7679
  target = { backend: "tmux", session, window, socket: resolve(socket) };
@@ -6789,17 +7681,23 @@ export function startInstanceSession(home, o = {}) {
6789
7681
  // Keep launch evidence until the command exits or the target disappears.
6790
7682
  // A transient child (for example cat TASK.md) is not proof that startup
6791
7683
  // has finished. A later start reconciles the receipt without a watcher.
6792
- try { return record(meta, { id, backend, target, model, command, startedAt, reused }, false); }
7684
+ try { return record(meta, { id, backend, target, model, command, startedAt, reused, ...planExtra, ...(stopReceipt ? { stop: stopReceipt } : {}) }, false); }
6793
7685
  catch (e) {
6794
7686
  if (e.code && String(e.code).startsWith("E_")) throw e;
6795
7687
  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`);
6796
7688
  }
6797
7689
  } finally {
6798
- rmSync(lock, { recursive: true, force: true });
7690
+ // A hook may have replaced the home itself. Never follow that replacement
7691
+ // to remove a target's lock; keep the original retry state with its home.
7692
+ try {
7693
+ const st = lstatSync(realHome);
7694
+ if (st.isDirectory() && !st.isSymbolicLink() && st.dev === originalHomeIdentity?.dev && st.ino === originalHomeIdentity?.ino) rmSync(lock, { recursive: true, force: true });
7695
+ } catch { /* retain retry state when its authority is lost */ }
6799
7696
  }
6800
7697
  }
6801
7698
 
6802
- function inspectRetirementWork(home, work, isWorktree, { branchDeletion } = {}) {
7699
+ function inspectRetirementWork(home, work, isWorktree, { branchDeletion, directory = false } = {}) {
7700
+ if (directory) assertDirectoryRoots(home);
6803
7701
  const classes = [];
6804
7702
  let baseline;
6805
7703
  const path = retirementBaselinePath(home);
@@ -6814,7 +7712,18 @@ function inspectRetirementWork(home, work, isWorktree, { branchDeletion } = {})
6814
7712
  } else if (baseline.homeFingerprint !== fingerprintTree(home, { excludeRoot: new Set(["work"]) })) {
6815
7713
  classes.push("changed instance-home bytes");
6816
7714
  }
6817
- const branchCommits = branchDeletion?.delete ? branchOnlyCommits(branchDeletion.repo, branchDeletion.branch) : undefined;
7715
+ // A mutable mode must not turn owned directory bytes into an excluded shared
7716
+ // tree (or authorize Git deletion). Require the independent spawn authority.
7717
+ if (directory !== (baselineValid && baseline.directoryWork === true)) {
7718
+ throw oatsError("E_WORK_INSPECTION_FAILED", "directory work mode disagrees with independent retirement authority");
7719
+ }
7720
+ let directoryFingerprint;
7721
+ if (directory) {
7722
+ directoryFingerprint = fingerprintTree(work);
7723
+ // Never stamp hook-created or authored execution bytes as disposable.
7724
+ if (readdirSync(work).length) classes.push("directory work bytes");
7725
+ }
7726
+ const branchCommits = !directory && branchDeletion?.delete ? branchOnlyCommits(branchDeletion.repo, branchDeletion.branch) : undefined;
6818
7727
  if (branchCommits?.length) classes.push("branch-only local commits");
6819
7728
  if (isWorktree && existsSync(work)) {
6820
7729
  const status = worktreeStatus(work);
@@ -6826,9 +7735,9 @@ function inspectRetirementWork(home, work, isWorktree, { branchDeletion } = {})
6826
7735
  }
6827
7736
  const stateFingerprint = createHash("sha256")
6828
7737
  .update(fingerprintTree(home, { excludeRoot: new Set(["work"]) }))
6829
- .update("\0").update(isWorktree && existsSync(work) ? worktreeStatus(work) : "")
7738
+ .update("\0").update(directory ? (directoryFingerprint || "missing") : isWorktree && existsSync(work) ? worktreeStatus(work) : "")
6830
7739
  .digest("hex");
6831
- return { classes: [...new Set(classes)], home, work, stateFingerprint, branchExists: branchCommits !== null, runtimeAuthority: baselineValid ? runtimeAuthorityOf(baseline) : undefined };
7740
+ return { classes: [...new Set(classes)], home, work, directory, directoryFingerprint, stateFingerprint, branchExists: branchCommits !== null, runtimeAuthority: baselineValid ? runtimeAuthorityOf(baseline) : undefined };
6832
7741
  }
6833
7742
 
6834
7743
  function copyRecoveryTree(src, dest, { excludeRoot = new Set() } = {}) {
@@ -6933,6 +7842,9 @@ function materializeNestedRepositories(sourceWork, recoveredRepo) {
6933
7842
  function preserveRetirementWork(observation, meta, instance) {
6934
7843
  const recoveryRoot = join(retirementStateRoot(observation.home), "recovery");
6935
7844
  mkdirSync(recoveryRoot, { recursive: true });
7845
+ if (observation.directory && realpathSync(recoveryRoot) !== join(realpathSync(dirname(observation.home)), ".oats-retirement", "recovery")) {
7846
+ throw oatsError("E_WORK_PRESERVATION_FAILED", "directory recovery storage was redirected; retain the source home rather than copying into an unowned or disposable location");
7847
+ }
6936
7848
  const staging = mkdtempSync(join(recoveryRoot, `.${instance}-`));
6937
7849
  const recovery = join(recoveryRoot, basename(staging).slice(1));
6938
7850
  try {
@@ -6975,6 +7887,13 @@ function preserveRetirementWork(observation, meta, instance) {
6975
7887
  const sourceHead = execFileSync("git", ["-C", meta.repo, "rev-parse", `refs/heads/${meta.branch}`], { encoding: "utf8" , maxBuffer: GIT_MAX_BUFFER }).trim();
6976
7888
  if (recoveredHead !== sourceHead) throw new Error("recovery clone does not retain the instance branch tip");
6977
7889
  }
7890
+ if (observation.directory && observation.directoryFingerprint) {
7891
+ const recoveredWork = join(staging, "work");
7892
+ copyTreeSafe(observation.work, recoveredWork);
7893
+ if (fingerprintTree(observation.work) !== observation.directoryFingerprint || fingerprintTree(recoveredWork) !== observation.directoryFingerprint) {
7894
+ throw new Error("directory recovery verification disagreed with the inspected source");
7895
+ }
7896
+ }
6978
7897
  const repoCopy = homeOnly ? { copied: false, reason: "Only instance-home bytes changed; no work state requires a repository copy", source: meta.repo, branch: meta.branch } : undefined;
6979
7898
  writeFileSync(join(staging, "recovery.json"), JSON.stringify({ version: 1, instance, classes: observation.classes, sourceHome: observation.home, createdAt: new Date().toISOString(), ...(repoCopy ? { repoCopy } : {}) }, null, 2) + "\n", { mode: 0o600 });
6980
7899
  mkdirSync(dirname(recovery), { recursive: true });
@@ -7146,7 +8065,8 @@ export function retireInstance(root, name, o = {}) {
7146
8065
  // not finish) has no instance.json — it never got that far. Its marker carries
7147
8066
  // the cleanup descriptor in the same shape, so retire can rerun compensation
7148
8067
  // instead of silently skipping every hook and deleting the credentials.
7149
- const quarantinePath = join(found.home, ".oats-rollback-incomplete.json");
8068
+ const directoryFallbackPath = directoryRollbackPath(found.home);
8069
+ const quarantinePath = existsSync(directoryFallbackPath) ? directoryFallbackPath : join(found.home, ".oats-rollback-incomplete.json");
7150
8070
  let quarantine;
7151
8071
  let markerUnusable = false;
7152
8072
  // A marker means the home is quarantined, WHETHER OR NOT instance.json exists:
@@ -7193,8 +8113,9 @@ export function retireInstance(root, name, o = {}) {
7193
8113
  }
7194
8114
 
7195
8115
  const workPath = join(found.home, "work");
7196
- const isWorktree = meta.work === "worktree" ||
7197
- (existsSync(workPath) && !lstatSync(workPath).isSymbolicLink());
8116
+ const directory = meta.work === "directory";
8117
+ const isWorktree = !directory && (meta.work === "worktree" ||
8118
+ (existsSync(workPath) && !lstatSync(workPath).isSymbolicLink()));
7198
8119
  // A live runtime cannot establish a stable final work inspection of itself,
7199
8120
  // so self-retire never inspects, runs hooks, or removes anything here. It
7200
8121
  // persists the intent and hands the whole retirement to a detached process
@@ -7206,7 +8127,7 @@ export function retireInstance(root, name, o = {}) {
7206
8127
  // First inspection is non-destructive. Only after it succeeds may OATS quiesce
7207
8128
  // the managed runtime; recovery copying never races a live managed Pi.
7208
8129
  const branchDeletion = { delete: !!(o.deleteBranch || quarantine), repo: meta.repo, branch: meta.branch };
7209
- const initialObservation = inspectRetirementWork(found.home, workPath, isWorktree, { branchDeletion });
8130
+ const initialObservation = inspectRetirementWork(found.home, workPath, isWorktree, { branchDeletion, directory });
7210
8131
  // Runtime identity is destructive authority. The mutable child metadata may
7211
8132
  // describe it for humans, but only the independent baseline can authorize the
7212
8133
  // endpoint that proves quiescence.
@@ -7247,7 +8168,7 @@ export function retireInstance(root, name, o = {}) {
7247
8168
  if (!/no server running|failed to connect|can't find session|no sessions/i.test(detail)) throw oatsError("E_RUNTIME_QUIESCE_FAILED", `could not establish that ${runtimeSession}:${runtimeWindow} stopped on ${runtimeSocket}: ${detail || "tmux inspection failed"}`);
7248
8169
  }
7249
8170
  }
7250
- const stableObservation = inspectRetirementWork(found.home, workPath, isWorktree, { branchDeletion });
8171
+ const stableObservation = inspectRetirementWork(found.home, workPath, isWorktree, { branchDeletion, directory });
7251
8172
  const workRecoveries = [];
7252
8173
  if (stableObservation.classes.length) workRecoveries.push(preserveRetirementWork(stableObservation, meta, name));
7253
8174
  let workRecovery = workRecoveries.at(-1);
@@ -7294,7 +8215,7 @@ export function retireInstance(root, name, o = {}) {
7294
8215
 
7295
8216
  // Hooks are allowed to mutate the inspected tree, so inspect again after
7296
8217
  // them and preserve a separately verified post-hook snapshot when needed.
7297
- const finalObservation = inspectRetirementWork(found.home, workPath, isWorktree, { branchDeletion });
8218
+ const finalObservation = inspectRetirementWork(found.home, workPath, isWorktree, { branchDeletion, directory });
7298
8219
  if (finalObservation.classes.length && finalObservation.stateFingerprint !== stableObservation.stateFingerprint) {
7299
8220
  workRecoveries.push(preserveRetirementWork(finalObservation, meta, name));
7300
8221
  workRecovery = workRecoveries.at(-1);
@@ -7465,6 +8386,7 @@ export function retireInstance(root, name, o = {}) {
7465
8386
  const forced = !!(stillIncomplete && o.force);
7466
8387
  if (!o.keepDir && (!stillIncomplete || forced)) {
7467
8388
  rmSync(found.home, { recursive: true, force: true });
8389
+ rmSync(directoryFallbackPath, { force: true });
7468
8390
  // The owed retirement is paid: clear the pending marker, and the failed
7469
8391
  // outcome an earlier deferred attempt may have left beside the home; a
7470
8392
  // deferred completion writes its own outcome after this returns.