@awebai/oats 0.26.0 → 0.27.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/core.mjs CHANGED
@@ -24,7 +24,7 @@
24
24
  * soul.yaml (flat key: value):
25
25
  * name, description, kind (persistent|local), type (optional agent-type/family, targeted by config),
26
26
  * repo (path rel. to workspace or absolute),
27
- * work (worktree|checkout|attached|workspace|directory), runtime (pi|claude|codex), model (pi model pattern, optional)
27
+ * work (worktree|checkout|attached|workspace|directory), harness (pi|claude|codex), model (pi model pattern, optional)
28
28
  * (attached as soul default is for service agents — spawn must supply workDir)
29
29
  */
30
30
  import { execFileSync, execSync, spawn as spawnProcess, spawnSync } from "node:child_process";
@@ -36,6 +36,7 @@ import { accessSync, constants as fsConstants } from "node:fs";
36
36
  import { createHash, randomUUID } from "node:crypto";
37
37
  import { fileURLToPath } from "node:url";
38
38
  import { initializeNativeHistory, prepareNativeStart } from "../packages/record/lib/native-history.mjs";
39
+ import { noteRuntimeName } from "./deprecation.mjs";
39
40
  import { attachSessionTarget } from "./session-viewer.mjs";
40
41
  import { inspectSessionTarget, inputSessionTarget } from "./session-input.mjs";
41
42
  import { appendEvent } from "./instance-events.mjs";
@@ -133,7 +134,7 @@ export const PACKAGED_SKILLS_DIR = join(PKG_ROOT, "skills");
133
134
  // ---------- shell helpers ----------
134
135
  function sh(cmdline) { return execSync(cmdline, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim(); }
135
136
  function shTry(cmdline) { try { return sh(cmdline); } catch { return undefined; } }
136
- /** A native PROBE (a runtime binary asked about its catalogue or packages) under
137
+ /** A native PROBE (a harness binary asked about its catalogue or packages) under
137
138
  * a preview: bounded by what is left of the shared preflight budget, run in its
138
139
  * own process group and group-killed on timeout. Cheap lookups (`command -v`)
139
140
  * are not probes and never draw from the budget. Outside a preview: shTry. */
@@ -501,14 +502,17 @@ export const LAYERS = ["knowledge", "messaging", "tasks"];
501
502
 
502
503
 
503
504
  // ---------- launch configurations ----------
504
- // A named way to start a harness, independent of any soul: the runtime, an
505
+ // A named way to start a harness, independent of any soul: the harness, an
505
506
  // executable (a wrapper, another binary), literal argv, environment (literal
506
507
  // values, or references resolved on the execution host at start time), a
507
508
  // model and yolo. Declared by the host under `launch-configs:` in the
508
509
  // deployment's oats-local.yaml (lead decision 2: a spawn-time HOST choice,
509
510
  // never a soul field). Selected at spawn or session start/restart by name.
510
- export const LAUNCH_RUNTIMES = ["pi", "claude", "codex"];
511
- export const LAUNCH_CONFIG_KEYS = new Set(["runtime", "executable", "args", "env", "model", "yolo"]);
511
+ export const LAUNCH_HARNESSES = ["pi", "claude", "codex"];
512
+ export const LAUNCH_CONFIG_KEYS = new Set(["harness", "runtime", "executable", "args", "env", "model", "yolo"]);
513
+ /** A launch configuration's harness: `harness`, or `runtime`, its pre-0.27 name (0.26.0
514
+ * deployments wrote it) — read either (lead call 6). */
515
+ export const launchConfigHarness = (entry) => (Object.hasOwn(entry, "harness") ? entry.harness : entry.runtime);
512
516
  const LAUNCH_CONFIG_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
513
517
  const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
514
518
  /** Environment the kernel sets for every launch (identity, home, roots) and
@@ -521,8 +525,9 @@ export function validateLaunchConfig(name, entry, where) {
521
525
  if (typeof name !== "string" || !LAUNCH_CONFIG_NAME.test(name)) bad("has an invalid name (letters, digits, dot, underscore, dash; up to 64 characters)");
522
526
  if (name === "none") bad("cannot be named none: that word selects no configuration");
523
527
  if (!entry || typeof entry !== "object" || Array.isArray(entry)) bad("must be a map");
524
- 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)`);
525
- if (!LAUNCH_RUNTIMES.includes(entry.runtime)) bad(`needs runtime: one of ${LAUNCH_RUNTIMES.join(", ")}`);
528
+ for (const key of Object.keys(entry)) if (!LAUNCH_CONFIG_KEYS.has(key)) bad(`has an unsupported key ${JSON.stringify(key)} (harness, executable, args, env, model, yolo)`);
529
+ if (Object.hasOwn(entry, "harness") && Object.hasOwn(entry, "runtime") && entry.harness !== entry.runtime) bad(`names harness ${JSON.stringify(entry.harness)} and runtime ${JSON.stringify(entry.runtime)}: \`runtime\` is the pre-0.27 name of \`harness\` — keep one`);
530
+ if (!LAUNCH_HARNESSES.includes(launchConfigHarness(entry))) bad(`needs harness: one of ${LAUNCH_HARNESSES.join(", ")}`);
526
531
  const text = (v, what) => { if (typeof v !== "string" || !v.trim() || v.includes("\0")) bad(`${what} must be non-empty text`); };
527
532
  if (entry.executable !== undefined) text(entry.executable, "executable");
528
533
  if (entry.args !== undefined) {
@@ -563,7 +568,8 @@ export function launchConfigsAt(dir) {
563
568
  validateLaunchConfigs(map, found.path);
564
569
  const source = dirname(found.path);
565
570
  for (const [name, entry] of Object.entries(map || {})) {
566
- 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, shadows: [] };
571
+ if (!Object.hasOwn(entry, "harness")) noteRuntimeName(`launch-configs.${name}.runtime in ${found.path}`);
572
+ out[name] = { name, harness: launchConfigHarness(entry), ...(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, shadows: [] };
567
573
  }
568
574
  return out;
569
575
  }
@@ -640,6 +646,18 @@ function validateCapabilityManifest(m, mf) {
640
646
  *
641
647
  * `__proto__` starts with `_`, so a JSON document's own `__proto__` key is
642
648
  * dropped here too rather than re-assigned through the inherited setter. */
649
+ /** A flat soul.yaml names its harness `harness:` (0.27.0) or, as released
650
+ * souls and capability-defined agents do (oats.aweb 1.13.1), `runtime:` —
651
+ * set both, read either until no released package writes `runtime`. Both,
652
+ * disagreeing, is refused. Returns the soul with `harness` only. */
653
+ export function soulHarnessField(soul, file) {
654
+ if (!soul || !Object.hasOwn(soul, "runtime")) return soul;
655
+ const { runtime, ...rest } = soul;
656
+ if (Object.hasOwn(soul, "harness") && soul.harness !== runtime) {
657
+ throw oatsError("E_BAD_MANIFEST", `${file} declares harness: ${soul.harness} and runtime: ${runtime}; \`runtime\` is the pre-0.27 name of \`harness\` — keep one`, { file, harness: soul.harness, runtime });
658
+ }
659
+ return { ...rest, harness: Object.hasOwn(soul, "harness") ? soul.harness : runtime };
660
+ }
643
661
  export function stripInternalAnnotations(parsed) {
644
662
  const out = {};
645
663
  for (const key of Object.keys(parsed)) if (!key.startsWith("_")) out[key] = parsed[key];
@@ -1150,7 +1168,7 @@ export function planInstanceResources({ resolved, soulDir, agent, contextDir, co
1150
1168
  return expected;
1151
1169
  }
1152
1170
 
1153
- // ---------- runtime packages (satisfied by a runtime's own package manager) ----------
1171
+ // ---------- harness packages (satisfied by a harness's own package manager) ----------
1154
1172
 
1155
1173
  /** pi's config dir, officially relocatable via PI_CODING_AGENT_DIR (pi
1156
1174
  * docs/usage.md). Hard-coding ~/.pi/agent reports an installed package as
@@ -1160,14 +1178,14 @@ export function planInstanceResources({ resolved, soulDir, agent, contextDir, co
1160
1178
  * package root sends lookups to the wrong tree (reviewer-ad1b9f0). */
1161
1179
  const piAgentDir = (env = process.env) => env.PI_CODING_AGENT_DIR || join(env.HOME || "", ".pi", "agent");
1162
1180
 
1163
- /** Runtimes whose own package manager can satisfy a requirement. A runtime
1164
- * package is NOT a command on PATH: it is registered with the runtime, so both
1165
- * detection and post-install verification read that runtime's package list.
1166
- * Null-prototype: `runtime:` comes from a soul or a package manifest, and
1167
- * `RUNTIME_PACKAGE_MANAGERS[runtime]` must answer for the runtimes declared
1181
+ /** Harnesses whose own package manager can satisfy a requirement. A harness
1182
+ * package is NOT a command on PATH: it is registered with the harness, so both
1183
+ * detection and post-install verification read that harness's package list.
1184
+ * Null-prototype: `harness:` comes from a soul or a package manifest, and
1185
+ * `HARNESS_PACKAGE_MANAGERS[harness]` must answer for the harnesses declared
1168
1186
  * here and for nothing else — an inherited `constructor` would pass the
1169
- * unknown-runtime gate and then be dereferenced as a manager. */
1170
- export const RUNTIME_PACKAGE_MANAGERS = {
1187
+ * unknown-harness gate and then be dereferenced as a manager. */
1188
+ export const HARNESS_PACKAGE_MANAGERS = {
1171
1189
  __proto__: null,
1172
1190
  pi: {
1173
1191
  scope: "user-level (pi packages)",
@@ -1253,15 +1271,15 @@ export const RUNTIME_PACKAGE_MANAGERS = {
1253
1271
  /** The executable is CONTEXT-SELECTED (oats-claude-config may name a wrapper
1254
1272
  * such as `claude-personal`). Probing and installing through the literal
1255
1273
  * `claude` would inspect a DIFFERENT account's plugins than the session
1256
- * actually launches with — passing preflight while the real runtime lacks
1274
+ * actually launches with — passing preflight while the real harness lacks
1257
1275
  * the channel, or rejecting one that has it (reviewer-6f1bb9c). */
1258
1276
  bin: (opts) => opts?.bin || "claude",
1259
- argv: (spec, req, opts) => [RUNTIME_PACKAGE_MANAGERS.claude.bin(opts), "plugin", "install", String(spec)],
1277
+ argv: (spec, req, opts) => [HARNESS_PACKAGE_MANAGERS.claude.bin(opts), "plugin", "install", String(spec)],
1260
1278
  /** A marketplace must be registered before installing from it, so the plan
1261
1279
  * is a SEQUENCE. Both steps are shown at the consent prompt: agreeing to a
1262
1280
  * plugin also means agreeing to the source it comes from. */
1263
1281
  steps: (spec, req, opts) => {
1264
- const bin = RUNTIME_PACKAGE_MANAGERS.claude.bin(opts);
1282
+ const bin = HARNESS_PACKAGE_MANAGERS.claude.bin(opts);
1265
1283
  return [
1266
1284
  ...(req?.marketplace ? [[bin, "plugin", "marketplace", "add", String(req.marketplace)]] : []),
1267
1285
  [bin, "plugin", "install", String(spec)],
@@ -1275,7 +1293,7 @@ export const RUNTIME_PACKAGE_MANAGERS = {
1275
1293
  list: (env = process.env, opts = {}) => {
1276
1294
  let out;
1277
1295
  try {
1278
- out = probeExecFile(RUNTIME_PACKAGE_MANAGERS.claude.bin(opts), ["plugin", "list", "--json"],
1296
+ out = probeExecFile(HARNESS_PACKAGE_MANAGERS.claude.bin(opts), ["plugin", "list", "--json"],
1279
1297
  { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 60000, env });
1280
1298
  } catch { return []; }
1281
1299
  let rows;
@@ -1292,7 +1310,7 @@ export const RUNTIME_PACKAGE_MANAGERS = {
1292
1310
  const owner = realPathOrNearest(r.projectPath);
1293
1311
  return target === owner || target.startsWith(owner + sep);
1294
1312
  })
1295
- // verifiedPresent is the "this runtime confirms presence without naming a
1313
+ // verifiedPresent is the "this harness confirms presence without naming a
1296
1314
  // location" override, NOT a blanket exemption: Claude DOES report
1297
1315
  // installPath, and setting it unconditionally meant a stale registration
1298
1316
  // whose install directory had been deleted still satisfied the spawn
@@ -1318,13 +1336,13 @@ export function packageSpecIdentity(spec) {
1318
1336
  return `${prefix}${scoped ? "@" : ""}${at >= 0 ? body.slice(0, at) : body}`;
1319
1337
  }
1320
1338
 
1321
- /** What the runtime reports about a required package: is it there, where did it
1339
+ /** What the harness reports about a required package: is it there, where did it
1322
1340
  * land, and does the user's entry filter its resources? A settings row alone is
1323
1341
  * NOT proof the capability's extension will load (reviewer-8518c49). */
1324
- /** The installed version of a runtime package: neither pi's nor Claude's
1342
+ /** The installed version of a harness package: neither pi's nor Claude's
1325
1343
  * listing reports one, so it is read from the package.json under the
1326
1344
  * install directory the listing names; undefined when there is none. */
1327
- export function installedRuntimePackageVersion(runtime, spec, status) {
1345
+ export function installedHarnessPackageVersion(harness, spec, status) {
1328
1346
  const dir = status?.dir;
1329
1347
  if (dir && existsSync(join(dir, "package.json"))) {
1330
1348
  try {
@@ -1342,11 +1360,11 @@ export function compareVersionTriples(a, b) {
1342
1360
  return 0;
1343
1361
  }
1344
1362
 
1345
- export function runtimePackageStatus(runtime, spec, env = process.env, opts = {}) {
1346
- const mgr = RUNTIME_PACKAGE_MANAGERS[runtime];
1363
+ export function harnessPackageStatus(harness, spec, env = process.env, opts = {}) {
1364
+ const mgr = HARNESS_PACKAGE_MANAGERS[harness];
1347
1365
  if (!mgr) return { installed: false };
1348
- const want = runtimePackageIdentity(runtime, spec);
1349
- const row = mgr.list(env, opts).find((r) => runtimePackageIdentity(runtime, r.source) === want);
1366
+ const want = harnessPackageIdentity(harness, spec);
1367
+ const row = mgr.list(env, opts).find((r) => harnessPackageIdentity(harness, r.source) === want);
1350
1368
  if (!row) return { installed: false };
1351
1369
  const filter = mgr.resourceFilter ? mgr.resourceFilter(spec, env) : undefined;
1352
1370
  return {
@@ -1357,7 +1375,7 @@ export function runtimePackageStatus(runtime, spec, env = process.env, opts = {}
1357
1375
  // No resolved directory means the package is configured but NOT installed:
1358
1376
  // pi omits the path line entirely in that case, so treating a missing line
1359
1377
  // as "fine" let a stale row through. A named directory that does not exist
1360
- // is the same condition, reported the same way. A runtime that confirms
1378
+ // is the same condition, reported the same way. A harness that confirms
1361
1379
  // presence without naming a directory (Claude's plugin list) says so via
1362
1380
  // verifiedPresent, so it is not judged by a path it never reports.
1363
1381
  missingFiles: row.verifiedPresent ? false : (!row.dir || !existsSync(row.dir)),
@@ -1370,22 +1388,22 @@ export function runtimePackageStatus(runtime, spec, env = process.env, opts = {}
1370
1388
  extensionsDisabled: !!filter?.disabled,
1371
1389
  };
1372
1390
  }
1373
- /** The identity of a runtime package, per that runtime's own naming. */
1374
- export function runtimePackageIdentity(runtime, spec) {
1375
- const mgr = RUNTIME_PACKAGE_MANAGERS[runtime];
1391
+ /** The identity of a harness package, per that harness's own naming. */
1392
+ export function harnessPackageIdentity(harness, spec) {
1393
+ const mgr = HARNESS_PACKAGE_MANAGERS[harness];
1376
1394
  return mgr?.identity ? mgr.identity(spec) : packageSpecIdentity(spec);
1377
1395
  }
1378
1396
 
1379
- /** Gate: a runtime package spec must be a plain source token — no shell syntax,
1397
+ /** Gate: a harness package spec must be a plain source token — no shell syntax,
1380
1398
  * whitespace, path traversal, or option-looking leading dash. Fail closed. */
1381
- export function safeRuntimePackageSpec(spec, runtime = "pi") {
1382
- const mgr = RUNTIME_PACKAGE_MANAGERS[runtime];
1399
+ export function safeHarnessPackageSpec(spec, harness = "pi") {
1400
+ const mgr = HARNESS_PACKAGE_MANAGERS[harness];
1383
1401
  return mgr?.safeSpec ? mgr.safeSpec(spec) : false;
1384
1402
  }
1385
1403
  /** A marketplace/source token a requirement may register before installing.
1386
1404
  * No shell syntax, whitespace, traversal or leading dash — it is passed as argv,
1387
1405
  * but a hostile value would still name an attacker-chosen source. */
1388
- export function safeRuntimeSourceRef(ref) {
1406
+ export function safeHarnessSourceRef(ref) {
1389
1407
  return typeof ref === "string" && /^[a-z0-9][\w.-]*(\/[a-z0-9][\w.-]*)*$/i.test(ref);
1390
1408
  }
1391
1409
 
@@ -1396,9 +1414,9 @@ export function safeRuntimeSourceRef(ref) {
1396
1414
  * plus the team/workspace facts (teamEnv: OATS_TEAM_SCOPE/_ID/_LABEL, OATS_WORKSPACE_NAME/_KEY);
1397
1415
  * cwd = the instance home. A hook may print JSON { meta, brief, warning, launch, env } — meta
1398
1416
  * is persisted per capability in instance.json (and fed back as OATS_META at retire), brief
1399
- * is added to TASK.md, warning surfaces in the spawn result; launch maps runtime → extra
1417
+ * is added to TASK.md, warning surfaces in the spawn result; launch maps harness → extra
1400
1418
  * launch-command arguments (spawn IS session start: the command built here is stored in
1401
- * instance.json and runs in the tmux window; a capability integrating a runtime — e.g.
1419
+ * instance.json and runs in the tmux window; a capability integrating a harness — e.g.
1402
1420
  * aweb's Claude Code channel plugin — contributes its flags this way). A hook the
1403
1421
  * capability declares REQUIRED fails the spawn and rolls it back; every other
1404
1422
  * hook failure is advisory and only warns. `env` contributes string environment
@@ -1417,10 +1435,10 @@ function validateHookEnvironment(capabilityID, value, owners, declarations) {
1417
1435
  throw new HookEnvironmentContractError(`${capabilityID} hook env requires a dotted lowercase alphanumeric vendor component; hyphen, @, and / forms cannot claim an environment namespace`);
1418
1436
  }
1419
1437
  const prefix = `${vendorComponent.toUpperCase()}_`;
1420
- // The runtime half of the same contract the manifest validator enforces:
1438
+ // The harness half of the same contract the manifest validator enforces:
1421
1439
  // a hook may set names under its vendor prefix or under a namespace its
1422
1440
  // trusted manifest declared (environmentNamespaces), and only names the
1423
- // manifest listed. Manifest and runtime must permit exactly the same set.
1441
+ // manifest listed. Manifest and harness must permit exactly the same set.
1424
1442
  const declared = declarations.get(capabilityID) || { names: new Set(), namespaces: [] };
1425
1443
  const allowed = [prefix, ...(declared.namespaces || [])];
1426
1444
  const accepted = {};
@@ -1577,8 +1595,8 @@ export function runLifecycleHooks(event, { home, instance, agentName, soulDir, s
1577
1595
  if (o.warning) results.warnings.push(o.warning);
1578
1596
  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}`;
1579
1597
  // Provenance of what this capability contributed to the launch: which
1580
- // runtimes it answered launch args for, and which env names, under
1581
- // which settings and trust. A later start or runtime switch reads this.
1598
+ // harnesses it answered launch args for, and which env names, under
1599
+ // which settings and trust. A later start or harness switch reads this.
1582
1600
  // A launch hook's run is recorded even when its answer is empty: an
1583
1601
  // empty answer replaces what the provider contributed before.
1584
1602
  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)) {
@@ -1652,7 +1670,7 @@ function readSoul(agentDir, soulDir = soulOf(agentDir)) {
1652
1670
  try { const v = parseConfigData(text, { format: "yaml" }).value; if (v && typeof v === "object" && !Array.isArray(v)) parsed = v; }
1653
1671
  catch { /* unreadable as YAML: the flat view is what the classic readers saw */ }
1654
1672
  }
1655
- const soul = stripInternalAnnotations(parsed);
1673
+ const soul = soulHarnessField(stripInternalAnnotations(parsed), p);
1656
1674
  soul._dir = agentDir;
1657
1675
  soul.name = soul.name || basename(agentDir);
1658
1676
  return soul;
@@ -1680,7 +1698,7 @@ function capabilityAgentMetadata(manifest, rel) {
1680
1698
  if (!soulDir || !soulFile) return undefined;
1681
1699
  // Read only contained identity metadata to decide whether this provider owns
1682
1700
  // the requested name. Full tree containment + trust happen after a match.
1683
- const soul = stripInternalAnnotations(withConfigFile(soulFile, () => parseYamlFlat(readFileSync(soulFile, "utf8"))));
1701
+ const soul = soulHarnessField(stripInternalAnnotations(withConfigFile(soulFile, () => parseYamlFlat(readFileSync(soulFile, "utf8")))), soulFile);
1684
1702
  return { soulDir, soul, name: soul.name || basename(soulDir) };
1685
1703
  }
1686
1704
  /**
@@ -1928,7 +1946,7 @@ export function tmuxWindows(session = DEFAULT_TMUX_SESSION) {
1928
1946
 
1929
1947
  /**
1930
1948
  * Spawn an instance of `agent` (as returned by findAgent/listAgents).
1931
- * o: { instance?, purpose?, name?, repo?, work?, runtime?, model?, task?, taskFile?, branch?, launch?, tmuxSession? }
1949
+ * o: { instance?, purpose?, name?, repo?, work?, harness?, model?, task?, taskFile?, branch?, launch?, tmuxSession? }
1932
1950
  */
1933
1951
  /** The claude binary for a context: closest `oats-claude-config` (a one-line file
1934
1952
  * naming the binary, e.g. "claude-personal") walking up from contextDir wins; no
@@ -1949,15 +1967,15 @@ export function resolveClaudeBinary(contextDir) {
1949
1967
  }
1950
1968
 
1951
1969
  /** Resolve a model preference LIST (comma-separated "provider/id[:thinking]" patterns)
1952
- * to the first entry whose provider/model is actually available to the runtime.
1970
+ * to the first entry whose provider/model is actually available to the harness.
1953
1971
  * pi: checked against `pi --list-models <pattern>` (authenticated providers).
1954
1972
  * claude: pi-style patterns are translated (anthropic/<id> → <id>) or dropped —
1955
1973
  * claude takes aliases/bare claude-* ids only; nothing usable → "" (claude default).
1956
1974
  * codex: translate openai/openai-codex entries to native ids, otherwise use its default.
1957
1975
  * Probe failures: first entry wins (pi errors loudly at launch). */
1958
- export function resolveModelPreference(model, runtime = "pi") {
1976
+ export function resolveModelPreference(model, harness = "pi") {
1959
1977
  const prefs = String(model || "").split(",").map((s) => s.trim()).filter(Boolean);
1960
- if (runtime === "codex") {
1978
+ if (harness === "codex") {
1961
1979
  for (const pref of prefs) {
1962
1980
  const bare = pref.replace(/:[a-z]+$/i, "");
1963
1981
  if (!bare.includes("/")) return bare;
@@ -1966,10 +1984,10 @@ export function resolveModelPreference(model, runtime = "pi") {
1966
1984
  }
1967
1985
  return ""; // let Codex use its configured model rather than another provider's id
1968
1986
  }
1969
- if (runtime === "claude") {
1987
+ if (harness === "claude") {
1970
1988
  // Claude accepts its aliases and bare claude-* ids — NOT pi-style
1971
1989
  // "provider/model[:thinking]" patterns. Agents whose soul default is a
1972
- // pi model are routinely runtime-overridden to claude; passing the pi
1990
+ // pi model are routinely harness-overridden to claude; passing the pi
1973
1991
  // pattern through makes claude reject the model at launch (operator
1974
1992
  // report, dev-coordinator-claude-sessions). Translate anthropic-provider
1975
1993
  // entries to the bare id (strip provider + :thinking) and drop other
@@ -1983,7 +2001,7 @@ export function resolveModelPreference(model, runtime = "pi") {
1983
2001
  return "";
1984
2002
  }
1985
2003
  if (prefs.length <= 1) return prefs[0] || "";
1986
- if (runtime !== "pi") return prefs[0];
2004
+ if (harness !== "pi") return prefs[0];
1987
2005
  for (const pref of prefs) {
1988
2006
  const bare = pref.replace(/:[a-z]+$/i, ""); // strip :<thinking> for the catalog probe
1989
2007
  const [provider, ...rest] = bare.split("/");
@@ -2003,9 +2021,9 @@ export function resolveModelPreference(model, runtime = "pi") {
2003
2021
  // "unrelated" is the no-link default (normalized away before recording).
2004
2022
  export const RELATIONS = ["child", "sibling", "parent", "unrelated"];
2005
2023
 
2006
- /** Verify the runtime packages that ACTIVE capabilities require for `runtime`.
2024
+ /** Verify the harness packages that ACTIVE capabilities require for `harness`.
2007
2025
  *
2008
- * Each comes from a declared runtime-package requirement, so the capability has
2026
+ * Each comes from a declared harness-package requirement, so the capability has
2009
2027
  * stated the dependency and the user has consented to install it (`oats install`).
2010
2028
  * We verify PRESENCE and record provenance; we deliberately do NOT resolve the
2011
2029
  * extension's entry file. pi owns that resolution — its manifest supports globs
@@ -2017,7 +2035,13 @@ export const RELATIONS = ["child", "sibling", "parent", "unrelated"];
2017
2035
  * Absence still fails the spawn: "aweb on pi requires the aweb pi package" is a
2018
2036
  * promise the instance's INSTRUCTIONS rely on, so starting without it would
2019
2037
  * leave the agent believing it can be woken by mail when it cannot. */
2020
- function verifyRuntimePackages(runtime, resolved, contextDir, { bin, env } = {}) {
2038
+ /** A harness-package requirement names its harness `harness` (0.27.0) or, as
2039
+ * released manifests do (oats.aweb), `runtime`: read either, exactly one. */
2040
+ export function requirementHarness(row) {
2041
+ if (!row || typeof row !== "object") return undefined;
2042
+ return Object.hasOwn(row, "harness") ? row.harness : row.runtime;
2043
+ }
2044
+ function verifyHarnessPackages(harness, resolved, contextDir, { bin, env } = {}) {
2021
2045
  const probeEnv = env || process.env;
2022
2046
  const found = [];
2023
2047
  const problems = [];
@@ -2026,61 +2050,63 @@ function verifyRuntimePackages(runtime, resolved, contextDir, { bin, env } = {})
2026
2050
  // `claude-personal`), so probing another binary would inspect a different
2027
2051
  // account's packages than the instance will actually use. The probe is the
2028
2052
  // manager's controlled list subcommand; no launch argument is added to it.
2029
- const probeOpts = { context: contextDir, ...(bin ? { bin } : runtime === "claude" ? { bin: resolveClaudeBinary(contextDir) } : {}) };
2053
+ const probeOpts = { context: contextDir, ...(bin ? { bin } : harness === "claude" ? { bin: resolveClaudeBinary(contextDir) } : {}) };
2030
2054
  for (const cap of resolved.capabilities || []) {
2031
2055
  for (const raw of cap.manifest?.requires || []) {
2032
- if (!raw || typeof raw !== "object" || raw.runtime !== runtime) continue;
2056
+ if (!raw || typeof raw !== "object") continue;
2057
+ if (Object.hasOwn(raw, "harness") && Object.hasOwn(raw, "runtime")) { problems.push(`${cap.id}: a requirement declares both \`harness\` and \`runtime\` (its pre-0.27 name); keep one`); continue; }
2058
+ if (requirementHarness(raw) !== harness) continue;
2033
2059
  // A requirement may be conditional on the capability's effective
2034
2060
  // settings (`when: { delivery: "channel" }`): rows whose condition does
2035
2061
  // not hold are not requirements of this spawn at all.
2036
2062
  if (raw.when !== undefined && (!raw.when || typeof raw.when !== "object" || Array.isArray(raw.when))) { problems.push(`${cap.id}: a requirement's \`when\` must be an object of setting names to values`); continue; }
2037
2063
  if (raw.when && !Object.entries(raw.when).every(([k, v]) => String(cap.settings?.[k] ?? "") === String(v))) continue;
2038
2064
  const spec = raw.package;
2039
- if (!safeRuntimePackageSpec(spec, runtime)) { problems.push(`${cap.id}: ${runtime} package spec is not a plain source token (${JSON.stringify(spec)})`); continue; }
2040
- if (raw.marketplace !== undefined && !safeRuntimeSourceRef(raw.marketplace)) { problems.push(`${cap.id}: marketplace is not a plain source reference (${JSON.stringify(raw.marketplace)})`); continue; }
2041
- const status = runtimePackageStatus(runtime, spec, probeEnv, probeOpts);
2042
- const mgr = RUNTIME_PACKAGE_MANAGERS[runtime];
2065
+ if (!safeHarnessPackageSpec(spec, harness)) { problems.push(`${cap.id}: ${harness} package spec is not a plain source token (${JSON.stringify(spec)})`); continue; }
2066
+ if (raw.marketplace !== undefined && !safeHarnessSourceRef(raw.marketplace)) { problems.push(`${cap.id}: marketplace is not a plain source reference (${JSON.stringify(raw.marketplace)})`); continue; }
2067
+ const status = harnessPackageStatus(harness, spec, probeEnv, probeOpts);
2068
+ const mgr = HARNESS_PACKAGE_MANAGERS[harness];
2043
2069
  const stepList = mgr?.steps ? mgr.steps(spec, raw, probeOpts) : [mgr?.argv(spec, raw, probeOpts) || []];
2044
2070
  const direct = stepList.filter((a) => a.length).map((a) => a.join(" ")).join(" && ");
2045
- const remedy = `run \`oats install --accept-requirement ${runtime}:${runtimePackageIdentity(runtime, spec)} --dir ${contextDir}\`${direct ? ` (or \`${direct}\` directly)` : ""}`;
2071
+ const remedy = `run \`oats install --accept-requirement ${harness}:${harnessPackageIdentity(harness, spec)} --dir ${contextDir}\`${direct ? ` (or \`${direct}\` directly)` : ""}`;
2046
2072
  // `ifInstalled: true`: the row constrains a package that may be absent
2047
2073
  // (an ambient extension must honour a contract IF it is there); absence
2048
2074
  // satisfies it. Without the flag, absence fails as before.
2049
- if (!status.installed) { if (raw.ifInstalled === true) continue; problems.push(`${cap.id} requires the ${runtime} package ${spec}, which is not installed — ${remedy}`); continue; }
2075
+ if (!status.installed) { if (raw.ifInstalled === true) continue; problems.push(`${cap.id} requires the ${harness} package ${spec}, which is not installed — ${remedy}`); continue; }
2050
2076
  // A settings row is not proof the extension loads. Both of these leave the
2051
2077
  // capability silently absent, which is the loss this gate exists to stop.
2052
- if (status.unverified) { problems.push(`${cap.id} requires the ${runtime} package ${spec}: it is configured, but OATS could not verify it is installed (${status.unverified}) — a config entry is not an installation; ${remedy}`); continue; }
2078
+ if (status.unverified) { problems.push(`${cap.id} requires the ${harness} package ${spec}: it is configured, but OATS could not verify it is installed (${status.unverified}) — a config entry is not an installation; ${remedy}`); continue; }
2053
2079
  if (status.missingFiles) {
2054
2080
  problems.push(status.dir
2055
- ? `${cap.id} requires the ${runtime} package ${spec}: ${runtime} lists it at ${status.dir}, but nothing is installed there — ${remedy}`
2056
- : `${cap.id} requires the ${runtime} package ${spec}: ${runtime} has it configured but reports no installed location, so it was never installed — ${remedy}`);
2081
+ ? `${cap.id} requires the ${harness} package ${spec}: ${harness} lists it at ${status.dir}, but nothing is installed there — ${remedy}`
2082
+ : `${cap.id} requires the ${harness} package ${spec}: ${harness} has it configured but reports no installed location, so it was never installed — ${remedy}`);
2057
2083
  continue;
2058
2084
  }
2059
- if (status.disabled) { problems.push(`${cap.id} requires the ${runtime} package ${spec}, which is installed but DISABLED, so it will not load — enable it (\`${probeOpts.bin || runtime} plugin enable ${spec}\` for Claude), or drop the capability for this soul`); continue; }
2060
- if (status.extensionsDisabled) { problems.push(`${cap.id} requires the ${runtime} package ${spec}, but your ${runtime} settings entry sets "extensions": [], which loads none of them — remove that filter, or drop the capability for this soul`); continue; }
2085
+ if (status.disabled) { problems.push(`${cap.id} requires the ${harness} package ${spec}, which is installed but DISABLED, so it will not load — enable it (\`${probeOpts.bin || harness} plugin enable ${spec}\` for Claude), or drop the capability for this soul`); continue; }
2086
+ if (status.extensionsDisabled) { problems.push(`${cap.id} requires the ${harness} package ${spec}, but your ${harness} settings entry sets "extensions": [], which loads none of them — remove that filter, or drop the capability for this soul`); continue; }
2061
2087
  // A floor on the installed version (`minVersion`), checked once presence
2062
2088
  // and loadability are settled: a package whose manifest states an older
2063
2089
  // version, or none, fails closed with the same remedy, since an old
2064
2090
  // extension that ignores a newer contract (AWEB_DELIVERY) is exactly
2065
2091
  // what the floor guards.
2066
2092
  if (raw.minVersion) {
2067
- const have = installedRuntimePackageVersion(runtime, spec, status);
2068
- if (!have) { problems.push(`${cap.id} requires the ${runtime} package ${spec} at ${raw.minVersion} or later, but its installed version cannot be established (no package manifest under its install directory) — ${remedy}`); continue; }
2069
- if (compareVersionTriples(have, raw.minVersion) < 0) { problems.push(`${cap.id} requires the ${runtime} package ${spec} at ${raw.minVersion} or later; ${have} is installed — ${remedy}`); continue; }
2093
+ const have = installedHarnessPackageVersion(harness, spec, status);
2094
+ if (!have) { problems.push(`${cap.id} requires the ${harness} package ${spec} at ${raw.minVersion} or later, but its installed version cannot be established (no package manifest under its install directory) — ${remedy}`); continue; }
2095
+ if (compareVersionTriples(have, raw.minVersion) < 0) { problems.push(`${cap.id} requires the ${harness} package ${spec} at ${raw.minVersion} or later; ${have} is installed — ${remedy}`); continue; }
2070
2096
  }
2071
2097
  if (status.extensionsFilter?.length) {
2072
2098
  // Unverifiable, not merely auditable: proving the filter selects this
2073
2099
  // capability's extension means implementing pi's glob semantics, and
2074
2100
  // guessing here is how an instance ends up promising a channel it does
2075
2101
  // not have. A filter on other resource kinds (skills) is unaffected.
2076
- problems.push(`${cap.id} requires the ${runtime} package ${spec}, but your ${runtime} settings entry filters its extensions (${status.extensionsFilter.map((e) => JSON.stringify(e)).join(", ")}). OATS cannot verify that filter selects the required extension without reimplementing ${runtime}'s matcher — remove the "extensions" filter for this package (a skills-only filter is fine), or drop the capability for this soul`);
2102
+ problems.push(`${cap.id} requires the ${harness} package ${spec}, but your ${harness} settings entry filters its extensions (${status.extensionsFilter.map((e) => JSON.stringify(e)).join(", ")}). OATS cannot verify that filter selects the required extension without reimplementing ${harness}'s matcher — remove the "extensions" filter for this package (a skills-only filter is fine), or drop the capability for this soul`);
2077
2103
  continue;
2078
2104
  }
2079
- found.push({ capability: cap.id, runtime, package: spec, identity: runtimePackageIdentity(runtime, spec), dir: status.dir, filtered: status.filtered });
2105
+ found.push({ capability: cap.id, harness, package: spec, identity: harnessPackageIdentity(harness, spec), dir: status.dir, filtered: status.filtered });
2080
2106
  }
2081
2107
  }
2082
2108
  if (problems.length) {
2083
- throw oatsError("E_RUNTIME_RESOURCE_MISSING", `this instance runs on ${runtime}, and its active capabilities require runtime packages that are not installed:\n${problems.map((p) => ` ${p}`).join("\n")}`);
2109
+ throw oatsError("E_HARNESS_RESOURCE_MISSING", `this instance runs on ${harness}, and its active capabilities require harness packages that are not installed:\n${problems.map((p) => ` ${p}`).join("\n")}`);
2084
2110
  }
2085
2111
  const seen = new Set();
2086
2112
  return found.filter((x) => (seen.has(x.identity) ? false : seen.add(x.identity))).sort((a, b) => a.identity.localeCompare(b.identity));
@@ -2089,16 +2115,37 @@ function verifyRuntimePackages(runtime, resolved, contextDir, { bin, env } = {})
2089
2115
  // ---------- launch recipes ----------
2090
2116
  // What a harness start is made of, recorded in instance.json (`launch`) so a
2091
2117
  // later start or restart can re-render it, select another configuration, or
2092
- // switch runtime without guessing from the command string. The rendered
2118
+ // switch harness without guessing from the command string. The rendered
2093
2119
  // command (`command`) stays beside it, byte-identical to what spawn rendered
2094
- // before recipes existed when no configuration is selected.
2095
- export const LAUNCH_RECIPE_VERSION = 1;
2120
+ // before recipes existed when no configuration is selected. Version 2 (0.27.0)
2121
+ // names the harness `harness`; version 1 (0.26.0 homes) said `runtime` and is
2122
+ // read as version 2 (upgradeLaunchRecipe); the next start or restart rewrites it.
2123
+ export const LAUNCH_RECIPE_VERSION = 2;
2124
+ /** A recorded recipe as this kernel reads it: version 1 `{runtime}` (0.26.0) is
2125
+ * version 2 `{harness}` (lead call 6: read either, write new). Anything else is
2126
+ * returned as it is, for assertLaunchRecipe to judge. */
2127
+ export function upgradeLaunchRecipe(recipe) {
2128
+ if (!isPlainObject(recipe) || recipe.version !== 1 || Object.hasOwn(recipe, "harness")) return recipe;
2129
+ const { runtime, ...rest } = recipe;
2130
+ return { ...rest, version: LAUNCH_RECIPE_VERSION, harness: runtime };
2131
+ }
2132
+ /** A home's instance.json as this kernel reads it: 0.26.0 recorded `runtime` and a
2133
+ * version-1 recipe; both read as `harness` (lead call 6). Every reader of a home's
2134
+ * harness goes through here; the next start or restart writes the new names. */
2135
+ export function upgradeHomeMeta(meta, home) {
2136
+ if (!isPlainObject(meta)) return meta;
2137
+ const old = Object.hasOwn(meta, "runtime") || (isPlainObject(meta.launch) && meta.launch.version === 1 && !Object.hasOwn(meta.launch, "harness"));
2138
+ if (!old) return meta;
2139
+ noteRuntimeName(`instance.json of ${home || meta.home || meta.instance} (a 0.26.0 home: its next start or restart records harness)`);
2140
+ const { runtime, ...rest } = meta;
2141
+ return { ...rest, ...(runtime !== undefined || meta.harness !== undefined ? { harness: meta.harness ?? runtime } : {}), ...(meta.launch !== undefined ? { launch: upgradeLaunchRecipe(meta.launch) } : {}) };
2142
+ }
2096
2143
  const LAUNCH_PROMPT = { kind: "task-file", file: "TASK.md" };
2097
2144
 
2098
2145
  /** The executable a launch uses: a configuration's declared one (a bare name
2099
2146
  * on PATH; a path against the deployment directory when relative) or the
2100
- * runtime's default (claude through oats-claude-config). Never executed. */
2101
- export function resolveLaunchExecutable({ runtime, declared, declaringDir, contextDir }) {
2147
+ * harness's default (claude through oats-claude-config). Never executed. */
2148
+ export function resolveLaunchExecutable({ harness, declared, declaringDir, contextDir }) {
2102
2149
  if (declared) {
2103
2150
  if (declared.includes("/")) {
2104
2151
  const path = isAbsolute(declared) ? declared : resolve(declaringDir || contextDir, declared);
@@ -2107,10 +2154,10 @@ export function resolveLaunchExecutable({ runtime, declared, declaringDir, conte
2107
2154
  const found = which(declared);
2108
2155
  return { path: found || null, declared, resolvedFrom: "PATH", missing: found ? undefined : `${declared} binary not found on PATH` };
2109
2156
  }
2110
- const claudeBin = runtime === "claude" ? resolveClaudeBinary(contextDir) : undefined;
2111
- const name = runtime === "claude" ? claudeBin : runtime;
2157
+ const claudeBin = harness === "claude" ? resolveClaudeBinary(contextDir) : undefined;
2158
+ const name = harness === "claude" ? claudeBin : harness;
2112
2159
  const found = which(name);
2113
- 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)" : ""}` };
2160
+ return { path: found || null, declared: null, resolvedFrom: harness === "claude" && claudeBin !== "claude" ? "oats-claude-config" : "PATH", missing: found ? undefined : `${name} binary not found on PATH${claudeBin && claudeBin !== "claude" ? " (named by oats-claude-config)" : ""}` };
2114
2161
  }
2115
2162
  /** null when `path` is a regular executable file; otherwise why not. */
2116
2163
  export function checkLaunchExecutable(path) {
@@ -2126,58 +2173,58 @@ export function missingLaunchEnvRefs(configEnv, env = process.env) {
2126
2173
  return Object.values(configEnv || {}).filter((v) => v && typeof v === "object" && v.fromEnv && env[v.fromEnv] === undefined).map((v) => v.fromEnv).sort();
2127
2174
  }
2128
2175
  /** The selection a start makes: which configuration (explicit name, "none",
2129
- * a frozen recipe's, or the soul's default), and from it the runtime and
2130
- * model. A named configuration is a unit: an explicit runtime that disagrees
2131
- * with it is refused. On an existing home, --runtime alone leaves the old
2176
+ * a frozen recipe's, or the soul's default), and from it the harness and
2177
+ * model. A named configuration is a unit: an explicit harness that disagrees
2178
+ * with it is refused. On an existing home, --harness alone leaves the old
2132
2179
  * configuration behind (its executable and args are not carried), and a
2133
- * model never crosses runtimes: explicit or configured model, else the
2134
- * frozen model on the same runtime, else the runtime's native default. */
2135
- /** Sentinel a caller passes as `model` to mean "the runtime's own default, not the configured/soul preference". */
2180
+ * model never crosses harnesses: explicit or configured model, else the
2181
+ * frozen model on the same harness, else the harness's native default. */
2182
+ /** Sentinel a caller passes as `model` to mean "the harness's own default, not the configured/soul preference". */
2136
2183
  export const NATIVE_DEFAULT_MODEL = "@native-default";
2137
2184
  export function resolveLaunchSelection({ launchConfigs = {}, agent, frozen, selection = {} }) {
2138
2185
  const bad = (code, msg) => { throw oatsError(code, msg); };
2139
2186
  let wanted = selection.launchConfig;
2140
2187
  let config = null;
2141
- if (wanted === undefined && frozen && !selection.runtime) {
2188
+ if (wanted === undefined && frozen && !selection.harness) {
2142
2189
  // An ordinary start of an existing home runs what was recorded: its
2143
2190
  // configuration as captured (executable, args, env, references), whether
2144
2191
  // or not the scope still declares it that way. Only an explicit name
2145
2192
  // applies the current definition.
2146
2193
  // Named or not: the recorded executable (a saved wrapper, say), args and
2147
2194
  // env are what runs; later edits of the scope never change it.
2148
- 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 };
2195
+ config = { name: frozen.launchConfig || null, harness: frozen.harness, ...(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 };
2149
2196
  wanted = "none";
2150
2197
  }
2151
2198
  if (wanted === undefined) wanted = frozen ? "none" : (agent?.["launch-config"] || "none");
2152
2199
  if (wanted !== "none") {
2153
2200
  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`);
2154
2201
  config = launchConfigs[wanted];
2155
- 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)`);
2202
+ if (selection.harness && selection.harness !== config.harness) bad("E_LAUNCH_CONFIG_MISMATCH", `launch configuration ${wanted} starts ${config.harness}; --harness ${selection.harness} disagrees with it (select another configuration, or --launch-config none with --harness)`);
2156
2203
  }
2157
- const runtime = config?.runtime || selection.runtime || (frozen ? frozen.runtime : agent?.runtime || "pi");
2158
- if (!LAUNCH_RUNTIMES.includes(runtime)) bad("E_UNSUPPORTED_RUNTIME", `unknown runtime "${runtime}" (pi|claude|codex)`);
2204
+ const harness = config?.harness || selection.harness || (frozen ? frozen.harness : agent?.harness || "pi");
2205
+ if (!LAUNCH_HARNESSES.includes(harness)) bad("E_UNSUPPORTED_HARNESS", `unknown harness "${harness}" (pi|claude|codex)`);
2159
2206
  let model, modelSource;
2160
- // K6: an EXPLICIT "use the runtime's native default" is distinct from an
2207
+ // K6: an EXPLICIT "use the harness's native default" is distinct from an
2161
2208
  // omitted model (which inherits the configuration's or soul's preference).
2162
2209
  const nativeDefault = selection.model === NATIVE_DEFAULT_MODEL || (selection.model && typeof selection.model === "object" && selection.model.kind === "native-default");
2163
2210
  const explicit = !nativeDefault && selection.model !== undefined && selection.model !== null && String(selection.model).trim() !== "";
2164
2211
  if (nativeDefault) {
2165
2212
  model = ""; modelSource = "native default (explicit)";
2166
2213
  } else if (explicit) {
2167
- model = resolveModelPreference(String(selection.model), runtime); modelSource = "explicit";
2168
- if (!model) bad("E_MODEL_UNKNOWN", `model preference ${JSON.stringify(selection.model)} has no entry usable by runtime ${runtime}; give a ${runtime} model id`);
2214
+ model = resolveModelPreference(String(selection.model), harness); modelSource = "explicit";
2215
+ if (!model) bad("E_MODEL_UNKNOWN", `model preference ${JSON.stringify(selection.model)} has no entry usable by harness ${harness}; give a ${harness} model id`);
2169
2216
  } else if (config?.model) {
2170
- model = config.frozen ? config.model : resolveModelPreference(config.model, runtime); modelSource = config.frozen ? "recorded" : `launch-config ${config.name}`;
2171
- if (!model) bad("E_MODEL_UNKNOWN", `launch configuration ${config.name} names model ${JSON.stringify(config.model)}, which has no entry usable by runtime ${runtime}`);
2217
+ model = config.frozen ? config.model : resolveModelPreference(config.model, harness); modelSource = config.frozen ? "recorded" : `launch-config ${config.name}`;
2218
+ if (!model) bad("E_MODEL_UNKNOWN", `launch configuration ${config.name} names model ${JSON.stringify(config.model)}, which has no entry usable by harness ${harness}`);
2172
2219
  } else if (frozen) {
2173
- if (frozen.runtime === runtime) { model = frozen.model || ""; modelSource = model ? "recorded" : "native default"; }
2174
- else { model = ""; modelSource = "native default (runtime changed)"; }
2175
- } else if (runtime !== (agent?.runtime || "pi")) {
2176
- // A soul's model preference belongs to the soul's runtime; a bare alias
2220
+ if (frozen.harness === harness) { model = frozen.model || ""; modelSource = model ? "recorded" : "native default"; }
2221
+ else { model = ""; modelSource = "native default (harness changed)"; }
2222
+ } else if (harness !== (agent?.harness || "pi")) {
2223
+ // A soul's model preference belongs to the soul's harness; a bare alias
2177
2224
  // is no proof it fits another one. Nothing is passed across.
2178
- model = ""; modelSource = "native default (runtime differs from the soul's)";
2179
- } else { model = resolveModelPreference(agent?.model || "", runtime); modelSource = model ? "soul default" : "native default"; }
2180
- return { config, runtime, model, modelSource, configuredYolo: config?.yolo };
2225
+ model = ""; modelSource = "native default (harness differs from the soul's)";
2226
+ } else { model = resolveModelPreference(agent?.model || "", harness); modelSource = model ? "soul default" : "native default"; }
2227
+ return { config, harness, model, modelSource, configuredYolo: config?.yolo };
2181
2228
  }
2182
2229
  /** The pane environment carrying each reference's value under a
2183
2230
  * kernel-owned alias (OATS_LAUNCH_REF_<NAME>), which no configuration can
@@ -2195,8 +2242,8 @@ export function launchEnvExports(recipe, env) { return launchEnvRefs(recipe, env
2195
2242
 
2196
2243
  /** The harness command line of a recipe. With no configuration the bytes
2197
2244
  * equal what spawn rendered before recipes: env prefix, the executable, the
2198
- * runtime's own arguments, capability launch args, the task prompt. A
2199
- * configuration's args go after the runtime's own options and before
2245
+ * harness's own arguments, capability launch args, the task prompt. A
2246
+ * configuration's args go after the harness's own options and before
2200
2247
  * capability args; for claude/codex the `--` separator keeps them from
2201
2248
  * swallowing the task, for pi they follow the task like capability args.
2202
2249
  *
@@ -2208,14 +2255,14 @@ export function launchEnvExports(recipe, env) { return launchEnvRefs(recipe, env
2208
2255
  * greedy contributed flag cannot eat it. codex keeps its native policy;
2209
2256
  * yolo also trusts this generated home for the launch (projects=...). */
2210
2257
  export function renderLaunchRecipe(recipe, { home, instance, redact = false }) {
2211
- const { runtime, executable, model, yolo } = recipe;
2258
+ const { harness, executable, model, yolo } = recipe;
2212
2259
  const cfgArgs = (recipe.args || []).map(shq).join(" ");
2213
- const hookArgs = recipe.hooks?.launch?.[runtime] || "";
2260
+ const hookArgs = recipe.hooks?.launch?.[harness] || "";
2214
2261
  const tail = `${cfgArgs ? ` ${cfgArgs}` : ""}${hookArgs ? ` ${hookArgs}` : ""}`;
2215
2262
  let cmdline;
2216
- if (runtime === "claude") {
2263
+ if (harness === "claude") {
2217
2264
  cmdline = `${shq(executable)}${yolo ? " --dangerously-skip-permissions" : ""}${model ? ` --model ${shq(model)}` : ""}${tail} -- "$(cat TASK.md)"`;
2218
- } else if (runtime === "codex") {
2265
+ } else if (harness === "codex") {
2219
2266
  const codexTrust = `projects={${JSON.stringify(realPathOrNearest(home))}={trust_level="trusted"}}`;
2220
2267
  cmdline = `${shq(executable)} --cd ${shq(home)}${yolo ? ` --yolo -c ${shq(codexTrust)}` : ""}${model ? ` --model ${shq(model)}` : ""}${tail} -- "$(cat TASK.md)"`;
2221
2268
  } else {
@@ -2237,12 +2284,12 @@ export function renderLaunchRecipe(recipe, { home, instance, redact = false }) {
2237
2284
  /** Execution, not preview: mark pending before dispatch, then resolve native
2238
2285
  * storage inside the backend shell under the actual command environment.
2239
2286
  * The original executable/argv is exec'd unchanged after recording succeeds. */
2240
- function nativeRecordCommand(command, home, runtime) {
2287
+ function nativeRecordCommand(command, home, harness) {
2241
2288
  const { tokens, binary } = parseLaunchCommand(command);
2242
2289
  const args = tokens.slice(binary + 1).filter(t => t.kind !== "prompt").map(t => t.value ?? t.text);
2243
- const id = prepareNativeStart(home, runtime);
2290
+ const id = prepareNativeStart(home, harness);
2244
2291
  const recorder = join(PKG_ROOT, "packages", "record", "bin", "record-native-start.mjs");
2245
- 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(" ")}`;
2292
+ const inner = `${shq(process.execPath)} ${shq(recorder)} ${shq(home)} ${shq(id)} ${shq(harness)} ${shq(JSON.stringify(args))} && exec ${tokens.slice(binary).map(t => t.text).join(" ")}`;
2246
2293
  return `${tokens.slice(0, binary).map(t => t.text).join(" ")} /bin/sh -c ${shq(inner)}`;
2247
2294
  }
2248
2295
 
@@ -2252,8 +2299,9 @@ function nativeRecordCommand(command, home, runtime) {
2252
2299
  export function assertLaunchRecipe(recipe, what) {
2253
2300
  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`); };
2254
2301
  if (!recipe || typeof recipe !== "object") bad("not an object");
2302
+ recipe = upgradeLaunchRecipe(recipe);
2255
2303
  if (recipe.version !== LAUNCH_RECIPE_VERSION) bad(`version ${JSON.stringify(recipe.version)}, expected ${LAUNCH_RECIPE_VERSION}`);
2256
- if (!LAUNCH_RUNTIMES.includes(recipe.runtime)) bad(`runtime ${JSON.stringify(recipe.runtime)}`);
2304
+ if (!LAUNCH_HARNESSES.includes(recipe.harness)) bad(`harness ${JSON.stringify(recipe.harness)}`);
2257
2305
  if (typeof recipe.executable !== "string" || !recipe.executable) bad("no executable");
2258
2306
  if (!Array.isArray(recipe.args) || recipe.args.some((a) => typeof a !== "string")) bad("args are not a list of strings");
2259
2307
  if (!recipe.env || typeof recipe.env !== "object" || Array.isArray(recipe.env)) bad("env is not a map");
@@ -2263,18 +2311,18 @@ export function assertLaunchRecipe(recipe, what) {
2263
2311
  if (!recipe.hooks || typeof recipe.hooks !== "object") bad("no hooks record");
2264
2312
  return recipe;
2265
2313
  }
2266
- /** The runtime-package requirements that apply: declared for this runtime
2314
+ /** The harness-package requirements that apply: declared for this harness
2267
2315
  * and, for a conditional row, holding under the provider's (captured)
2268
2316
  * settings. Nothing else is probed or restricted. */
2269
- export function applicableRequirements(runtime, providers) {
2317
+ export function applicableRequirements(harness, providers) {
2270
2318
  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)));
2271
2319
  const out = [];
2272
- 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 });
2320
+ for (const cap of providers || []) for (const r of cap.manifest?.requires || []) if (r && typeof r === "object" && requirementHarness(r) === harness && holds(cap, r)) out.push({ capability: cap.id, package: r.package });
2273
2321
  return out;
2274
2322
  }
2275
- function requirementsWithArgsMessage(runtime, providers, config) {
2276
- const rows = applicableRequirements(runtime, providers);
2277
- 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`;
2323
+ function requirementsWithArgsMessage(harness, providers, config) {
2324
+ const rows = applicableRequirements(harness, providers);
2325
+ return `launch configuration ${config?.name || "(recorded)"} passes arguments (${config.args.map((a) => JSON.stringify(a)).join(", ")}) that the ${harness} 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`;
2278
2326
  }
2279
2327
  /** ONE planner for what a start would run, used by preview and by starts of
2280
2328
  * existing homes alike: the recorded recipe (or, under a selection, the
@@ -2294,28 +2342,28 @@ export function planLaunch({ home, instance, meta, contextDir, agentLike, select
2294
2342
  // found walking up from the home) — never the context directory, which may be a
2295
2343
  // member clone outside the deployment.
2296
2344
  const chosen = resolveLaunchSelection({ launchConfigs: launchConfigs || (home ? launchConfigsAt(home) : resolvedCfg?.launchConfigs) || {}, agent: agentLike, frozen, selection });
2297
- const { config, runtime, model, modelSource } = chosen;
2345
+ const { config, harness, model, modelSource } = chosen;
2298
2346
  const yolo = resolveYolo(selection.yolo ?? chosen.configuredYolo ?? (frozen ? frozen.yolo : agentLike?.yolo ?? resolvedCfg?.yolo));
2299
2347
  const executable = config?.frozen
2300
2348
  ? { path: config.executablePath, declared: frozen.executableDeclared ?? null, resolvedFrom: frozen.executableResolvedFrom || "recorded", missing: existsSync(config.executablePath) ? undefined : `${config.executablePath} (recorded) does not exist` }
2301
- : resolveLaunchExecutable({ runtime, declared: config?.executable, declaringDir: config?.source, contextDir });
2349
+ : resolveLaunchExecutable({ harness, declared: config?.executable, declaringDir: config?.source, contextDir });
2302
2350
  const exeProblem = executable.path ? checkLaunchExecutable(executable.path) : executable.missing;
2303
- 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})` });
2304
- // Capability contributions: recorded at spawn with provenance; a runtime
2305
- // switch needs the new runtime's launch args from the same capabilities.
2351
+ if (exeProblem) fail("executable", "E_LAUNCH_EXECUTABLE", `launch configuration ${config?.name || "(harness default)"}: ${exeProblem}`); else problems.push({ check: "executable", ok: true, detail: `${executable.path} (${executable.resolvedFrom})` });
2352
+ // Capability contributions: recorded at spawn with provenance; a harness
2353
+ // switch needs the new harness's launch args from the same capabilities.
2306
2354
  let hooks = { launch: {}, env: {}, contributions: [], pending: true };
2307
2355
  if (frozen) {
2308
2356
  // Recorded contributions, refreshed by capabilities that declare a
2309
- // launch hook; a runtime change needs the new runtime's arguments from
2310
- // every capability that gave runtime-specific ones; recorded arguments
2357
+ // launch hook; a harness change needs the new harness's arguments from
2358
+ // every capability that gave harness-specific ones; recorded arguments
2311
2359
  // of a capability the scope no longer trusts are not reused.
2312
2360
  try {
2313
- hooks = prepareLaunchHooks({ frozen, runtime, resolvedCfg, home, meta, contextDir, assertRoots });
2361
+ hooks = prepareLaunchHooks({ frozen, harness, resolvedCfg, home, meta, contextDir, assertRoots });
2314
2362
  const current = new Map((resolvedCfg?.capabilities || []).map((c) => [c.id, c]));
2315
2363
  const untrusted = hooks.contributions.filter((c) => c.capability && current.has(c.capability) && !current.get(c.capability).trust?.trusted).map((c) => c.capability);
2316
2364
  const inactive = hooks.contributions.filter((c) => c.capability && !current.has(c.capability)).map((c) => c.capability);
2317
2365
  if (untrusted.length) fail("capabilities", "E_LAUNCH_PREPARATION", `${untrusted.join(", ")} contributed to this launch at spawn but is no longer trusted in the scope; respawn the instance; nothing was stopped`);
2318
- 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(", ")}` : ""}` });
2366
+ else problems.push({ check: "capabilities", ok: true, detail: `${harness !== frozen.harness ? "prepared for the new harness" : "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(", ")}` : ""}` });
2319
2367
  } catch (e) {
2320
2368
  if (e.code !== "E_LAUNCH_PREPARATION") throw e;
2321
2369
  fail("capabilities", e.code, e.message);
@@ -2333,41 +2381,41 @@ export function planLaunch({ home, instance, meta, contextDir, agentLike, select
2333
2381
  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`);
2334
2382
  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` });
2335
2383
  problems.push({ check: "model", ok: true, detail: model ? `${model} (${modelSource})` : `native default (${modelSource})` });
2336
- // Runtime package requirements: the captured providers' (bindings united
2384
+ // Harness package requirements: the captured providers' (bindings united
2337
2385
  // with contributions, by id, current manifest, captured settings for
2338
2386
  // conditional rows) for an existing home, the scope's for a new instance;
2339
2387
  // probed under the launch's EFFECTIVE environment with the selected
2340
- // executable, only when some requirement is declared for this runtime and
2388
+ // executable, only when some requirement is declared for this harness and
2341
2389
  // every reference resolved. Configuration arguments cannot be applied to a
2342
2390
  // package probe (it must never start a conversation), which is stated.
2343
2391
  if (executable.path && !missing.length && !owned.length) {
2344
2392
  const providers = frozen
2345
2393
  ? capturedProviders(meta, frozen).map((p) => { const dir = launchManifestDir(home, meta); const manifest = dir ? capabilityManifest(p.id, dir) : undefined; return manifest ? { id: p.id, manifest, settings: p.settings } : null; }).filter(Boolean)
2346
2394
  : (resolvedCfg?.capabilities || []);
2347
- const declared = applicableRequirements(runtime, providers).length > 0;
2395
+ const declared = applicableRequirements(harness, providers).length > 0;
2348
2396
  if (declared && (config?.args || []).length) {
2349
2397
  // The controlled probe cannot carry configuration arguments, so with
2350
2398
  // an applicable requirement a launch under such arguments cannot be
2351
2399
  // reported verified: refused, truthfully, with the way out.
2352
- fail("runtime-packages", "E_LAUNCH_PROBE_UNSUPPORTED", requirementsWithArgsMessage(runtime, providers, config));
2400
+ fail("harness-packages", "E_LAUNCH_PROBE_UNSUPPORTED", requirementsWithArgsMessage(harness, providers, config));
2353
2401
  } else if (declared) {
2354
2402
  try {
2355
- verifyRuntimePackages(runtime, { capabilities: providers }, contextDir, { ...(config?.executable || config?.frozen ? { bin: executable.path } : {}), env: launchEffectiveEnv({ base: env, hooksEnv: hooks.env, configEnv }) });
2356
- 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" : ""}` });
2357
- } catch (e) { fail("runtime-packages", "E_RUNTIME_PACKAGE", e.message); }
2358
- } else problems.push({ check: "runtime-packages", ok: true, detail: "no runtime package requirement declared for this runtime; nothing probed" });
2403
+ verifyHarnessPackages(harness, { capabilities: providers }, contextDir, { ...(config?.executable || config?.frozen ? { bin: executable.path } : {}), env: launchEffectiveEnv({ base: env, hooksEnv: hooks.env, configEnv }) });
2404
+ problems.push({ check: "harness-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" : ""}` });
2405
+ } catch (e) { fail("harness-packages", "E_HARNESS_PACKAGE", e.message); }
2406
+ } else problems.push({ check: "harness-packages", ok: true, detail: "no harness package requirement declared for this harness; nothing probed" });
2359
2407
  }
2360
2408
  const recipe = {
2361
- version: LAUNCH_RECIPE_VERSION, runtime, launchConfig: config?.name || null, launchConfigSource: config?.source || null,
2362
- executable: executable.path || executable.declared || runtime, executableDeclared: executable.declared ?? null, executableResolvedFrom: executable.resolvedFrom,
2409
+ version: LAUNCH_RECIPE_VERSION, harness, launchConfig: config?.name || null, launchConfigSource: config?.source || null,
2410
+ executable: executable.path || executable.declared || harness, executableDeclared: executable.declared ?? null, executableResolvedFrom: executable.resolvedFrom,
2363
2411
  args: [...(config?.args || [])], env: { ...configEnv }, model: model || null, ...(yolo !== undefined ? { yolo } : {}),
2364
2412
  hooks: frozen ? { launch: hooks.launch, env: hooks.env, contributions: hooks.contributions } : hooks, prompt: LAUNCH_PROMPT,
2365
2413
  ...(frozen?.legacy ? { legacy: { ...frozen.legacy, ...(hooks.refreshed?.length ? { replacedBy: hooks.refreshed } : {}) } } : {}),
2366
2414
  };
2367
2415
  const inst = instance || meta?.instance || basename(home);
2368
2416
  const command = renderLaunchRecipe(recipe, { home, instance: inst });
2369
- const selectionSource = frozen ? (config?.frozen || (!config && !selection.launchConfig && !selection.runtime) ? "frozen" : "config") : "config";
2370
- return { recipe, command, runtime, model: model || undefined, modelSource, yolo, config, executable, preflight: problems, ok: problems.every((c) => c.ok), selectionSource, frozen, ...(hooks.meta ? { hookMeta: hooks.meta } : {}) };
2417
+ const selectionSource = frozen ? (config?.frozen || (!config && !selection.launchConfig && !selection.harness) ? "frozen" : "config") : "config";
2418
+ return { recipe, command, harness, model: model || undefined, modelSource, yolo, config, executable, preflight: problems, ok: problems.every((c) => c.ok), selectionSource, frozen, ...(hooks.meta ? { hookMeta: hooks.meta } : {}) };
2371
2419
  }
2372
2420
  /** The environment a planned launch runs under: the host's base, the
2373
2421
  * capabilities' validated env, the configuration's literals and its
@@ -2460,17 +2508,17 @@ function* spawnBody(root, agent, o = {}) {
2460
2508
  const repoAbs = preparedDeployment ?? resolveExecutionContext(root, repoTarget, work);
2461
2509
  if (!repoAbs) throw oatsError("E_BAD_ARGS", `${agent.name}: no repository for a ${work} instance — pass --repo${work === "attached" ? " (the work tree's owner records none)" : ""}`);
2462
2510
  // Launch selection: a named configuration (explicit, or the soul's
2463
- // launch-config default), or none; the runtime and model follow from it.
2464
- // K6b: a preview's native probes (model catalogue, runtime packages) share
2511
+ // launch-config default), or none; the harness and model follow from it.
2512
+ // K6b: a preview's native probes (model catalogue, harness packages) share
2465
2513
  // ONE budget from here to the return; each probe is group-killed on timeout.
2466
2514
  const preflightStarted = Date.now(), preflightBudgetMs = o.preview === true ? (Number(process.env.OATS_PREVIEW_PREFLIGHT_BUDGET_MS) || 20000) : undefined;
2467
2515
  let preflight = { status: "complete", budgetMs: preflightBudgetMs ?? null };
2468
2516
  if (o.preview === true) previewPreflightBudget = { deadline: preflightStarted + preflightBudgetMs };
2469
2517
  let launchSelection;
2470
- try { launchSelection = resolveLaunchSelection({ launchConfigs: launchConfigsAt(root), agent, selection: { launchConfig: o.launchConfig, runtime: o.runtime, model: o.model } }); }
2518
+ try { launchSelection = resolveLaunchSelection({ launchConfigs: launchConfigsAt(root), agent, selection: { launchConfig: o.launchConfig, harness: o.harness, model: o.model } }); }
2471
2519
  catch (e) { previewPreflightBudget = null; throw e; }
2472
2520
  const launchConfig = launchSelection.config;
2473
- const runtime = launchSelection.runtime;
2521
+ const harness = launchSelection.harness;
2474
2522
  const model = launchSelection.model;
2475
2523
  // Instance homes belong in the soul-owning repo's PRIMARY checkout, never in a
2476
2524
  // linked worktree (see canonicalDeploymentPath). The CLI resolves this through
@@ -2807,17 +2855,17 @@ function* spawnBody(root, agent, o = {}) {
2807
2855
  const resolvedCfg = composition.resolved;
2808
2856
  const yolo = resolveYolo(o.yolo ?? launchSelection.configuredYolo);
2809
2857
  const expectedResources = planInstanceResources({ resolved: resolvedCfg, soulDir, agent, contextDir: repoAbs, composition, prepared: o.prepared });
2810
- // Runtime extensions selected by ACTIVE capabilities for THIS instance's
2811
- // runtime. Strict launch disables ambient extension discovery, so each one has
2812
- // to be named by path — and a required runtime package that is not installed
2858
+ // Harness extensions selected by ACTIVE capabilities for THIS instance's
2859
+ // harness. Strict launch disables ambient extension discovery, so each one has
2860
+ // to be named by path — and a required harness package that is not installed
2813
2861
  // must fail here, loudly, rather than produce an instance that silently lost
2814
- // its channel. `--runtime` can override a soul default long after install-time
2862
+ // its channel. `--harness` can override a soul default long after install-time
2815
2863
  // reconciliation, so this spawn-time check is the authoritative one.
2816
2864
 
2817
2865
  // Prerequisites must fail before creating a home, worktree, or identity.
2818
- const executable = resolveLaunchExecutable({ runtime, declared: launchConfig?.executable, declaringDir: launchConfig?.source, contextDir: repoAbs });
2866
+ const executable = resolveLaunchExecutable({ harness, declared: launchConfig?.executable, declaringDir: launchConfig?.source, contextDir: repoAbs });
2819
2867
  if (!executable.path) throw new Error(executable.missing);
2820
- { const bad = checkLaunchExecutable(executable.path); if (bad) throw oatsError("E_LAUNCH_EXECUTABLE", `launch configuration ${launchConfig?.name || "(runtime default)"}: ${bad}`); }
2868
+ { const bad = checkLaunchExecutable(executable.path); if (bad) throw oatsError("E_LAUNCH_EXECUTABLE", `launch configuration ${launchConfig?.name || "(harness default)"}: ${bad}`); }
2821
2869
  const bin = executable.path;
2822
2870
  const missingRefs = missingLaunchEnvRefs(launchConfig?.env || {}, process.env);
2823
2871
  if (missingRefs.length) throw oatsError("E_LAUNCH_ENV_MISSING", `launch configuration ${launchConfig.name} references ${missingRefs.join(", ")}, not set in this environment; nothing was created`);
@@ -2826,8 +2874,8 @@ function* spawnBody(root, agent, o = {}) {
2826
2874
  // under the configuration's environment (literals, references resolved
2827
2875
  // from the base); the capabilities' own env is not known before their
2828
2876
  // spawn hooks run, which happens after the home exists.
2829
- if ((launchConfig?.args || []).length && applicableRequirements(runtime, resolvedCfg.capabilities).length) throw oatsError("E_LAUNCH_PROBE_UNSUPPORTED", `${requirementsWithArgsMessage(runtime, resolvedCfg.capabilities, launchConfig)}; nothing was created`);
2830
- const runtimePackages = verifyRuntimePackages(runtime, resolvedCfg, repoAbs, { ...(launchConfig?.executable ? { bin } : {}), env: launchEffectiveEnv({ base: process.env, configEnv: launchConfig?.env || {} }) });
2877
+ if ((launchConfig?.args || []).length && applicableRequirements(harness, resolvedCfg.capabilities).length) throw oatsError("E_LAUNCH_PROBE_UNSUPPORTED", `${requirementsWithArgsMessage(harness, resolvedCfg.capabilities, launchConfig)}; nothing was created`);
2878
+ const harnessPackages = verifyHarnessPackages(harness, resolvedCfg, repoAbs, { ...(launchConfig?.executable ? { bin } : {}), env: launchEffectiveEnv({ base: process.env, configEnv: launchConfig?.env || {} }) });
2831
2879
  // Backend presence/startup (ensureHerdr) happens AFTER the decision fence
2832
2880
  // and the exclusive placement reservation below — a stale or losing apply
2833
2881
  // must not start a daemon. Preview never starts one either.
@@ -2857,7 +2905,7 @@ function* spawnBody(root, agent, o = {}) {
2857
2905
  // launch (inherited defaults re-resolved at apply must not drift silently).
2858
2906
  const buildDecision = () => {
2859
2907
  const d = { instance, home, branch: plannedBranch, base: plannedBase,
2860
- effective: { repo: repoAbs, work, runtime, model: model || null, launchConfig: launchConfig?.name ?? null, yolo: yolo ?? null, backend, // backend regardless of --no-launch: the decision is what WOULD launch
2908
+ effective: { repo: repoAbs, work, harness, model: model || null, launchConfig: launchConfig?.name ?? null, yolo: yolo ?? null, backend, // backend regardless of --no-launch: the decision is what WOULD launch
2861
2909
  childSpawns: ownChildPolicy.allowed, relation: relation ? { kind: relation, anchor: { instance: relativeTo ?? null, agentsRoot: anchorHome ? dirname(dirname(dirname(anchorHome))) : null } } : null } };
2862
2910
  // Workspace model: the decision binds WHAT WILL BE MATERIALIZED — the
2863
2911
  // resolution revision (member commits, package commits, payloads). A member
@@ -2900,7 +2948,7 @@ function* spawnBody(root, agent, o = {}) {
2900
2948
  subject: o.subject ?? { soul: agent.name, agentsRoot: root, context: null },
2901
2949
  decision, preflight,
2902
2950
  backendStatus: launch ? { name: backend, installed: !!which(backend), started: false } : null,
2903
- runtime, model: model || null, modelSource: launchSelection.modelSource ?? null, launchConfig: launchConfig?.name ?? null, yolo, backend,
2951
+ harness, model: model || null, modelSource: launchSelection.modelSource ?? null, launchConfig: launchConfig?.name ?? null, yolo, backend,
2904
2952
  branch: plannedBranch, base: plannedBase, worktree: work === "worktree" ? join(home, "work") : null,
2905
2953
  relation: relation || null, parentInstance: parentInstance && parentInstance !== instance ? parentInstance : null,
2906
2954
  policy: { childSpawns: ownChildPolicy }, executable: bin, capabilities: preparedCapabilities,
@@ -2944,7 +2992,7 @@ function* spawnBody(root, agent, o = {}) {
2944
2992
  throw Object.assign(oatsError("E_PLACEMENT_TAKEN", `${instance} was taken by a concurrent spawn of another soul (${other}); nothing was created by this call — preview again`), { instance, home: other });
2945
2993
  }
2946
2994
  }
2947
- // TOCTOU: the placement checks above ran BEFORE composition and the runtime
2995
+ // TOCTOU: the placement checks above ran BEFORE composition and the harness
2948
2996
  // package preflight, both of which shell out — a window in which anything able
2949
2997
  // to write in the agent directory can swap `instances/` for a link elsewhere,
2950
2998
  // and mkdirSync follows it (reviewer-a6aa1c5). Re-assert on the directory that
@@ -3229,7 +3277,7 @@ function* spawnBody(root, agent, o = {}) {
3229
3277
  const hookRes = runLifecycleHooks("spawn", {
3230
3278
  home, instance, agentName: agent.name, soulDir: homeSoulTarget, soulId: preparedSoulId, contextDir: repoAbs,
3231
3279
  workspaceDir: workspaceOf(root), rootDir: root, resolved: resolvedCfg,
3232
- extraEnv: { OATS_TASK: task, OATS_REPO: repoAbs, OATS_BRANCH: branch || "", OATS_WORK: work, OATS_RUNTIME: runtime, OATS_KIND: agent.kind || "persistent" },
3280
+ extraEnv: { OATS_TASK: task, OATS_REPO: repoAbs, OATS_BRANCH: branch || "", OATS_WORK: work, OATS_HARNESS: harness, OATS_RUNTIME: harness, OATS_KIND: agent.kind || "persistent" },
3233
3281
  });
3234
3282
  warnings.push(...hookRes.warnings);
3235
3283
  // Which capability hooks RAN (in order) and how each ended — recorded on the
@@ -3258,7 +3306,7 @@ function* spawnBody(root, agent, o = {}) {
3258
3306
  const outstandingHooks = new Set();
3259
3307
  const outstandingGit = new Set();
3260
3308
  // A failed new-window command may still have created its window. Verify
3261
- // quiescence before removing credentials or work that runtime may be using.
3309
+ // quiescence before removing credentials or work that harness may be using.
3262
3310
  if (windowMayExist && spawnHerdr) {
3263
3311
  try { stopHerdr(spawnHerdr); }
3264
3312
  catch (e) {
@@ -3387,7 +3435,7 @@ function* spawnBody(root, agent, o = {}) {
3387
3435
  let note;
3388
3436
  if (outstandingHooks.size || outstandingGit.size) {
3389
3437
  // Preserve credentials and the original hook receipt until cleanup
3390
- // succeeds. The runtime is stopped; Git cleanup was independently safe.
3438
+ // succeeds. The harness is stopped; Git cleanup was independently safe.
3391
3439
  note = quarantineInstanceHome({
3392
3440
  home, instance, agent, soulDir: homeSoulTarget, soulId: preparedSoulId, incomplete,
3393
3441
  failed,
@@ -3434,15 +3482,15 @@ function* spawnBody(root, agent, o = {}) {
3434
3482
  You are instance "${instance}" of agent "${agent.name}".
3435
3483
  - Home: ${home}
3436
3484
  - Work tree: ./work — ${workDesc}
3437
- - 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" : ""}
3485
+ - Do all repository work inside ./work. Read ./work/AGENTS.md or ./work/CLAUDE.md first if present.${briefLines}${harness === "codex" ? "\n## Harness 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" : ""}
3438
3486
  ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spawn time — await instructions.\n"}`);
3439
3487
 
3440
3488
  // Launch command. Spawn IS session start: the recipe is persisted in
3441
3489
  // instance.json beside its rendering, which is executed in the instance's
3442
- // tmux window. Capabilities contributed runtime-specific arguments and
3490
+ // tmux window. Capabilities contributed harness-specific arguments and
3443
3491
  // environment through their spawn hook; both are recorded with provenance.
3444
3492
  const recipe = {
3445
- version: LAUNCH_RECIPE_VERSION, runtime,
3493
+ version: LAUNCH_RECIPE_VERSION, harness,
3446
3494
  launchConfig: launchConfig?.name || null, launchConfigSource: launchConfig?.source || null,
3447
3495
  executable: bin, executableDeclared: executable.declared, executableResolvedFrom: executable.resolvedFrom,
3448
3496
  args: [...(launchConfig?.args || [])], env: { ...(launchConfig?.env || {}) },
@@ -3458,7 +3506,7 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
3458
3506
  const moduleSkills = (materializeOutcome?.skills || []).map((row) => ({ name: row.name, source: `module:${row.module}`, from: join(home, row.from) }));
3459
3507
  const meta = {
3460
3508
  agent: agent.name, kind: agent.kind || "persistent", instance, home, soulDir: homeSoulTarget,
3461
- repo: repoAbs, work, branch, runtime, model: model || undefined,
3509
+ repo: repoAbs, work, branch, harness, model: model || undefined,
3462
3510
  ...(yolo !== undefined ? { yolo } : {}),
3463
3511
  parentInstance: parentInstance && parentInstance !== instance ? parentInstance : undefined,
3464
3512
  siblingInstance: siblingInstance && siblingInstance !== instance ? siblingInstance : undefined,
@@ -3493,19 +3541,19 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
3493
3541
  instructions: composition.blocks.map((b) => ({ source: b.source, file: b.file })),
3494
3542
  // `filtered` records that the operator's settings entry narrows this
3495
3543
  // package's resources. A non-empty filter is a deliberate choice whose
3496
- // glob semantics belong to the runtime, so it is auditable here rather
3544
+ // glob semantics belong to the harness, so it is auditable here rather
3497
3545
  // than second-guessed at spawn.
3498
- runtimePackages: runtimePackages.map((x) => ({ capability: x.capability, runtime: x.runtime, package: x.package, dir: x.dir, filtered: x.filtered, loadedBy: "runtime-discovery" })),
3546
+ harnessPackages: harnessPackages.map((x) => ({ capability: x.capability, harness: x.harness, package: x.package, dir: x.dir, filtered: x.filtered, loadedBy: "harness-discovery" })),
3499
3547
  // What this instance ACTUALLY sees beyond the OATS-composed set. Recorded
3500
3548
  // so the deviation from strict composition is auditable instead of
3501
3549
  // implied — the honest contract, not an aspiration.
3502
- runtimePosture: runtime === "claude"
3550
+ harnessPosture: harness === "claude"
3503
3551
  ? {
3504
3552
  oatsComposed: "skills via .claude/skills -> ../.agents/skills; instructions via CLAUDE.md -> AGENTS.md",
3505
3553
  ambient: ["user skills", "project and ancestor skills to the repository root", "user and project plugins", "user and project settings", "user and ancestor CLAUDE.md"],
3506
3554
  why: "founder ruling: Claude Code's own global and per-repo configuration stays enabled — it is powerful, and the operator decides. An all-OATS setup is the way to opt out.",
3507
3555
  }
3508
- : runtime === "codex" ? {
3556
+ : harness === "codex" ? {
3509
3557
  oatsComposed: "skills via .agents/skills; instructions via AGENTS.md; task via initial prompt",
3510
3558
  ambient: ["user and ancestor instructions", "user, project, admin and system skills", "user and project configuration and MCP servers"],
3511
3559
  why: yolo ? "Codex keeps native configuration with approval prompts and sandbox disabled by the OATS yolo setting." : "Codex keeps the operator's native configuration and approval policy, including approval handling for work paths outside the home.",
@@ -3544,7 +3592,7 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
3544
3592
 
3545
3593
  spawnTmux = meta.tmux;
3546
3594
  if (work === "directory") assertDirectoryRoots(home, homeReal);
3547
- const executionCommand = launch ? nativeRecordCommand(cmdline, home, runtime) : null;
3595
+ const executionCommand = launch ? nativeRecordCommand(cmdline, home, harness) : null;
3548
3596
  if (launch && backend === "herdr") {
3549
3597
  windowMayExist = true;
3550
3598
  spawnHerdr = allocateHerdr(herdrBase, { home, instance });
@@ -3565,7 +3613,7 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
3565
3613
  meta.tmux.socket = tmuxSocket(session);
3566
3614
  meta.launched = true;
3567
3615
  // Commit the final child metadata and its independent byte authority before
3568
- // the managed runtime can write. No child-home transition follows launch.
3616
+ // the managed harness can write. No child-home transition follows launch.
3569
3617
  writeFileSync(join(home, "instance.json"), JSON.stringify(meta, null, 2) + "\n");
3570
3618
  writeRetirementBaseline(home, join(home, "work"), work, resolvedCfg.capabilities, { launched: true, tmux: meta.tmux });
3571
3619
  // Wrap the command so the window drops into an interactive shell when the
@@ -3609,8 +3657,8 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
3609
3657
  }
3610
3658
  }
3611
3659
 
3612
- appendEvent(home, { kind: "spawned", data: { agent: agent.name, work, branch: branch ?? null, runtime, model: model || null, parentInstance: meta.parentInstance ?? null, relation: relation ?? null, launched: launch, hooks: hookReceipt(hookRes) } });
3613
- if (launch) appendEvent(home, { kind: "launched", data: { runtime, backend, launchConfig: launchConfig?.name ?? null } });
3660
+ appendEvent(home, { kind: "spawned", data: { agent: agent.name, work, branch: branch ?? null, harness, model: model || null, parentInstance: meta.parentInstance ?? null, relation: relation ?? null, launched: launch, hooks: hookReceipt(hookRes) } });
3661
+ if (launch) appendEvent(home, { kind: "launched", data: { harness, backend, launchConfig: launchConfig?.name ?? null } });
3614
3662
  if (o.idempotencyKey !== undefined) {
3615
3663
  // Only now is the spawn a finished receipt a same-key retry may replay.
3616
3664
  meta.spawnCompleted = true;
@@ -3632,8 +3680,8 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
3632
3680
  export function servedIdentityOf(meta) {
3633
3681
  const cm = meta?.capabilityMeta;
3634
3682
  if (!cm || typeof cm !== "object") return null;
3635
- const runtime = Array.isArray(meta.capabilityRuntime) ? meta.capabilityRuntime : [];
3636
- const messaging = runtime.filter((c) => c && c.layer === "messaging").map((c) => c.id);
3683
+ const harness = Array.isArray(meta.capabilityRuntime) ? meta.capabilityRuntime : [];
3684
+ const messaging = harness.filter((c) => c && c.layer === "messaging").map((c) => c.id);
3637
3685
  const pick = (ids) => { for (const id of ids) { const v = cm[id]?.identity; if (v && typeof v === "object" && !Array.isArray(v)) return { ...v, provider: id }; } return null; };
3638
3686
  return pick(messaging) ?? pick(Object.keys(cm));
3639
3687
  }
@@ -3656,7 +3704,7 @@ export function listInstances(root, tmuxSession = DEFAULT_TMUX_SESSION) {
3656
3704
  const metaPath = join(instancesDir, e.name, "instance.json");
3657
3705
  const home = join(instancesDir, e.name);
3658
3706
  const meta = existsSync(metaPath)
3659
- ? JSON.parse(readFileSync(metaPath, "utf8"))
3707
+ ? upgradeHomeMeta(JSON.parse(readFileSync(metaPath, "utf8")), home)
3660
3708
  : { instance: e.name, home };
3661
3709
  // A home retained by an incomplete rollback is NOT a live instance: it
3662
3710
  // is preserved state awaiting cleanup, and must read that way.
@@ -4450,10 +4498,10 @@ export function capturedProviders(meta, frozen) {
4450
4498
  }
4451
4499
  /** Capability contributions for a start of an existing home: the recorded
4452
4500
  * ones, refreshed by any capability that declares a `launch` hook (asked
4453
- * for the target runtime; side-effect-free by contract; spawn hooks are
4454
- * never re-run). A runtime change needs the new runtime's launch arguments
4455
- * from every capability that contributed runtime-specific ones. */
4456
- export function prepareLaunchHooks({ frozen, runtime, resolvedCfg, home, meta, contextDir, extraEnv = {}, assertRoots }) {
4501
+ * for the target harness; side-effect-free by contract; spawn hooks are
4502
+ * never re-run). A harness change needs the new harness's launch arguments
4503
+ * from every capability that contributed harness-specific ones. */
4504
+ export function prepareLaunchHooks({ frozen, harness, resolvedCfg, home, meta, contextDir, extraEnv = {}, assertRoots }) {
4457
4505
  const contributions = (frozen.hooks?.contributions || []).map((c) => ({ ...c }));
4458
4506
  const env = { ...(frozen.hooks?.env || {}) };
4459
4507
  const refreshed = [];
@@ -4463,7 +4511,7 @@ export function prepareLaunchHooks({ frozen, runtime, resolvedCfg, home, meta, c
4463
4511
  // now: a captured provider that is no longer installed, or no longer
4464
4512
  // trusted, refuses the start; a provider bound to the scope after the
4465
4513
  // spawn is never adopted. A captured provider that declares a launch hook
4466
- // prepares the target runtime under its CAPTURED settings.
4514
+ // prepares the target harness under its CAPTURED settings.
4467
4515
  const ctx = launchManifestDir(home, meta);
4468
4516
  const withLaunchHook = [];
4469
4517
  let hookMeta;
@@ -4476,9 +4524,9 @@ export function prepareLaunchHooks({ frozen, runtime, resolvedCfg, home, meta, c
4476
4524
  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: [] });
4477
4525
  }
4478
4526
  if (withLaunchHook.length) {
4479
- const res = runLifecycleHooks("launch", { assertRoots, home, instance: meta.instance, agentName: meta.agent, soulDir: instanceSoulDir(home, meta), contextDir: ctx, rootDir: dirname(dirname(dirname(home))), resolved: { ...(resolvedCfg || {}), capabilities: withLaunchHook }, priorMeta: meta.capabilityMeta || {}, extraEnv: { OATS_RUNTIME: runtime, OATS_PREVIOUS_RUNTIME: frozen.runtime || "", ...extraEnv } });
4527
+ const res = runLifecycleHooks("launch", { assertRoots, home, instance: meta.instance, agentName: meta.agent, soulDir: instanceSoulDir(home, meta), contextDir: ctx, rootDir: dirname(dirname(dirname(home))), resolved: { ...(resolvedCfg || {}), capabilities: withLaunchHook }, priorMeta: meta.capabilityMeta || {}, extraEnv: { OATS_HARNESS: harness, OATS_PREVIOUS_HARNESS: frozen.harness || "", OATS_RUNTIME: harness, OATS_PREVIOUS_RUNTIME: frozen.harness || "", ...extraEnv } });
4480
4528
  const failed = (res.failures || []).map((f) => `${f.capability}: ${f.message}`);
4481
- if (failed.length) throw oatsError("E_LAUNCH_PREPARATION", `a capability could not prepare the ${runtime} launch:\n ${failed.join("\n ")}`);
4529
+ if (failed.length) throw oatsError("E_LAUNCH_PREPARATION", `a capability could not prepare the ${harness} launch:\n ${failed.join("\n ")}`);
4482
4530
  // Ownership holds across retained AND refreshed contributions, as the
4483
4531
  // spawn runner holds it across providers: a refreshed provider may
4484
4532
  // replace its own previous keys, never a key another provider retains.
@@ -4506,8 +4554,8 @@ export function prepareLaunchHooks({ frozen, runtime, resolvedCfg, home, meta, c
4506
4554
  // retire undid the wrong one. Carry it to the caller; the start records it.
4507
4555
  hookMeta = res.meta && Object.keys(res.meta).length ? res.meta : undefined;
4508
4556
  }
4509
- const unprepared = contributions.filter((c) => !refreshed.includes(c.capability) && c.launch && c.launch[frozen.runtime] !== undefined && c.launch[runtime] === undefined).map((c) => c.capability);
4510
- 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`);
4557
+ const unprepared = contributions.filter((c) => !refreshed.includes(c.capability) && c.launch && c.launch[frozen.harness] !== undefined && c.launch[harness] === undefined).map((c) => c.capability);
4558
+ if (unprepared.length) throw oatsError("E_LAUNCH_PREPARATION", `${unprepared.join(", ")} contributed ${frozen.harness} launch arguments at spawn and none for ${harness}; change that capability's setting (for example its delivery mode), or the provider must declare a launch hook; nothing was stopped`);
4511
4559
  const launch = {};
4512
4560
  for (const c of contributions) { for (const [rt, args] of Object.entries(c.launch || {})) if (args) launch[rt] = `${launch[rt] ? `${launch[rt]} ` : ""}${args}`; }
4513
4561
  return { launch, env, contributions, refreshed, ...(hookMeta ? { meta: hookMeta } : {}) };
@@ -4559,7 +4607,7 @@ export function startInstanceSession(home, o = {}) {
4559
4607
  const lock = join(realHome, ".oats-start.lock");
4560
4608
  const pendingPath = join(realHome, ".oats-start-pending.json");
4561
4609
  const exitedPath = join(realHome, ".oats-start-exited");
4562
- const readMeta = () => { try { return JSON.parse(readFileSync(metaPath, "utf8")); } catch (e) { throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", `cannot read ${metaPath}: ${e.message}`); } };
4610
+ const readMeta = () => { try { return upgradeHomeMeta(JSON.parse(readFileSync(metaPath, "utf8")), realHome); } catch (e) { throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", `cannot read ${metaPath}: ${e.message}`); } };
4563
4611
  // Workspace-model homes only (lead decision c3 Q2): a home an earlier kernel
4564
4612
  // spawned records no modules, and there is no scope configuration left to
4565
4613
  // re-resolve it against. Retire still works on it.
@@ -4575,7 +4623,7 @@ export function startInstanceSession(home, o = {}) {
4575
4623
  // The independent receipt first (retire and session consult it), then the
4576
4624
  // mutable metadata; both tmp+rename. A failure between them is what the
4577
4625
  // pending receipt exists for.
4578
- const record = (meta, { id, backend, target, model, command, startedAt, reused, launch, runtime: newRuntime, yolo: newYolo, stop, nativeRecordId, hookMeta }, clearPending = true) => {
4626
+ const record = (meta, { id, backend, target, model, command, startedAt, reused, launch, harness: newHarness, yolo: newYolo, stop, nativeRecordId, hookMeta }, clearPending = true) => {
4579
4627
  checkRoots();
4580
4628
  const baselinePath = retirementBaselinePath(realHome);
4581
4629
  let baseline;
@@ -4590,7 +4638,7 @@ export function startInstanceSession(home, o = {}) {
4590
4638
  const restarts = (Array.isArray(meta.restarts) ? meta.restarts : []).slice(recorded ? -20 : -19);
4591
4639
  if (!recorded) restarts.push({ startedAt, model: model ?? null, reused });
4592
4640
  const next = { ...meta, model, command, launched: true, startId: id, restarts, restartCount: (meta.restartCount || 0) + (recorded ? 0 : 1),
4593
- ...(launch ? { launch } : {}), ...(newRuntime ? { runtime: newRuntime } : {}), ...(newYolo !== undefined ? { yolo: newYolo } : {}),
4641
+ ...(launch ? { launch } : {}), ...(newHarness ? { harness: newHarness } : {}), ...(newYolo !== undefined ? { yolo: newYolo } : {}),
4594
4642
  // Launch-hook meta lands per capability over the spawn's record; a hook
4595
4643
  // that answered without meta keeps its previous entry (retire reads it).
4596
4644
  ...(hookMeta ? { capabilityMeta: { ...(meta.capabilityMeta || {}), ...hookMeta } } : {}) };
@@ -4598,7 +4646,7 @@ export function startInstanceSession(home, o = {}) {
4598
4646
  else { next.tmux = { session: target.session, window: target.window, socket: resolve(target.socket) }; delete next.sessionTarget; }
4599
4647
  writeJsonAtomic(metaPath, next);
4600
4648
  if (clearPending) { checkRoots(); rmSync(pendingPath, { force: true }); }
4601
- 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, ...(nativeRecordId ? { nativeRecordId } : {}), ...(stop ? { stop } : {}) };
4649
+ return { instance: meta.instance, agent: meta.agent, home: realHome, harness: next.harness, backend, model: model ?? null, launchConfig: next.launch?.launchConfig ?? null, yolo: next.yolo ?? null, target, startedAt, restartCount: next.restartCount, reused, ...(nativeRecordId ? { nativeRecordId } : {}), ...(stop ? { stop } : {}) };
4602
4650
  };
4603
4651
  try { mkdirSync(lock); }
4604
4652
  catch (e) {
@@ -4622,7 +4670,7 @@ export function startInstanceSession(home, o = {}) {
4622
4670
  try { parseLaunchCommand(pending?.command); validCommand = true; } catch { /* preserve invalid receipt below */ }
4623
4671
  let validLaunch = true;
4624
4672
  if (pending?.launch !== undefined) { try { assertLaunchRecipe(pending.launch, "the pending start"); } catch { validLaunch = false; } }
4625
- if (pending?.runtime !== undefined && !LAUNCH_RUNTIMES.includes(pending.runtime)) validLaunch = false;
4673
+ if (pending?.harness !== undefined && !LAUNCH_HARNESSES.includes(pending.harness)) validLaunch = false;
4626
4674
  if (pending?.yolo !== undefined && typeof pending.yolo !== "boolean") validLaunch = false;
4627
4675
  const validReceipt = validTarget && validCommand && validLaunch
4628
4676
  && typeof pending.id === "string" && /^[a-zA-Z0-9-]{1,80}$/.test(pending.id)
@@ -4652,9 +4700,9 @@ export function startInstanceSession(home, o = {}) {
4652
4700
  if (o.restart) { rmSync(pendingPath, { force: true }); }
4653
4701
  else {
4654
4702
  // The recovered target runs what the receipt says; a choice made
4655
- // now (model, configuration, runtime, yolo) was not applied to it.
4656
- 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`);
4657
- 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`);
4703
+ // now (model, configuration, harness, yolo) was not applied to it.
4704
+ if (o.model != null && String(o.model).trim() && resolveModelPreference(String(o.model), done.harness || meta.harness) !== 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`);
4705
+ if (o.launchConfig !== undefined || o.harness !== undefined || o.yolo !== undefined) throw oatsError("E_SESSION_RUNNING", `${meta.instance} is already running (its pending start was recovered); the requested launch configuration, harness or yolo was not applied; stop it, or use session restart`);
4658
4706
  if (meta.startId === pending.id) throw oatsError("E_SESSION_RUNNING", `${meta.instance} is already running; nothing was started`);
4659
4707
  return done;
4660
4708
  }
@@ -4663,8 +4711,8 @@ export function startInstanceSession(home, o = {}) {
4663
4711
  // 2. The ordinary gate and observation, all under the lock.
4664
4712
  const receipt = instanceSessionTarget(realHome);
4665
4713
  const meta = readMeta();
4666
- const runtime = meta.runtime;
4667
- if (!["pi", "claude", "codex"].includes(runtime)) throw oatsError("E_LAUNCH_COMMAND_UNSUPPORTED", `instance ${meta.instance || realHome} records runtime ${JSON.stringify(runtime)}, which this kernel cannot relaunch`);
4714
+ const harness = meta.harness;
4715
+ if (!["pi", "claude", "codex"].includes(harness)) throw oatsError("E_LAUNCH_COMMAND_UNSUPPORTED", `instance ${meta.instance || realHome} records harness ${JSON.stringify(harness)}, which this kernel cannot relaunch`);
4668
4716
  const backend = meta.sessionTarget || meta.backend === "herdr" ? "herdr" : "tmux";
4669
4717
  let command = meta.command;
4670
4718
  let model = meta.model || undefined;
@@ -4672,7 +4720,7 @@ export function startInstanceSession(home, o = {}) {
4672
4720
  // model, re-rendered in place), or, under a selection, the recipe
4673
4721
  // re-resolved against the home's current scoped configuration. Every
4674
4722
  // preflight happens here, before anything is observed or stopped.
4675
- const selected = o.launchConfig !== undefined || o.runtime !== undefined || o.yolo !== undefined;
4723
+ const selected = o.launchConfig !== undefined || o.harness !== undefined || o.yolo !== undefined;
4676
4724
  const hasRecipe = meta.launch && typeof meta.launch === "object";
4677
4725
  let launchPlan = null;
4678
4726
  if (selected || hasRecipe) {
@@ -4686,12 +4734,12 @@ export function startInstanceSession(home, o = {}) {
4686
4734
  // launch hook re-checks joined memberships against them (teams contract decision 6).
4687
4735
  const resolvedCfg = resolvedFromHome(realHome, meta, { teams: o.teams, teamsSource: o.teamsSource });
4688
4736
  let agent; try { agent = findAgent(dirname(dirname(dirname(realHome))), meta.agent); } catch { agent = undefined; }
4689
- 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 });
4690
- launchPlan = { recipe: plan.recipe, command: plan.command, runtime: plan.runtime, model: plan.model, yolo: plan.yolo, ...(plan.hookMeta ? { hookMeta: plan.hookMeta } : {}) };
4737
+ const plan = planLaunch({ home: realHome, instance: meta.instance, meta, contextDir: context, agentLike: agent || { harness: meta.harness, model: meta.model, yolo: meta.yolo }, selection: { launchConfig: o.launchConfig, harness: o.harness, model: o.model, yolo: o.yolo }, resolvedCfg, env: o.env || process.env, assertRoots: checkRoots });
4738
+ launchPlan = { recipe: plan.recipe, command: plan.command, harness: plan.harness, model: plan.model, yolo: plan.yolo, ...(plan.hookMeta ? { hookMeta: plan.hookMeta } : {}) };
4691
4739
  command = launchPlan.command; model = launchPlan.model;
4692
4740
  } else if (o.model !== undefined && o.model !== null && String(o.model).trim() !== "") {
4693
- const resolved = resolveModelPreference(String(o.model), runtime);
4694
- 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`);
4741
+ const resolved = resolveModelPreference(String(o.model), harness);
4742
+ if (!resolved) throw oatsError("E_MODEL_UNKNOWN", `model preference ${JSON.stringify(o.model)} has no entry usable by harness ${harness}; give a ${harness} model id`);
4695
4743
  command = withLaunchModel(command, resolved);
4696
4744
  model = resolved;
4697
4745
  } else parseLaunchCommand(command);
@@ -4703,7 +4751,7 @@ export function startInstanceSession(home, o = {}) {
4703
4751
  const paneEnvFlags = paneEnv.flatMap((r) => ["-e", `${r.name}=${r.value}`]);
4704
4752
  const paneEnvExports = paneEnv.map((r) => `export ${r.name}=${shq(r.value)}; `).join("");
4705
4753
  checkRoots(); // launch hooks/preparation have run; no backend has been observed
4706
- const planExtra = launchPlan ? { launch: launchPlan.recipe, runtime: launchPlan.runtime, yolo: launchPlan.yolo,
4754
+ const planExtra = launchPlan ? { launch: launchPlan.recipe, harness: launchPlan.harness, yolo: launchPlan.yolo,
4707
4755
  ...(launchPlan.hookMeta ? { hookMeta: launchPlan.hookMeta } : {}) } : {};
4708
4756
  let target = receipt.target;
4709
4757
  let state = { present: false, state: "not-launched" };
@@ -4722,7 +4770,7 @@ export function startInstanceSession(home, o = {}) {
4722
4770
  // end and wait, bounded. A harness still there afterwards is reported
4723
4771
  // as running; nothing is escalated and nothing is launched.
4724
4772
  stopReceipt = stopHarness(target, { graceMs: o.stopGraceMs ?? 20000, io: o.io, kill: o.io?.kill, sleep: o.io?.sleep });
4725
- 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);
4773
+ writeJsonAtomic(join(realHome, ".oats-restart.json"), { instance: meta.instance, at: new Date().toISOString(), stop: stopReceipt, next: { harness: launchPlan?.harness || harness, launchConfig: launchPlan?.recipe?.launchConfig ?? meta.launch?.launchConfig ?? null, model: model ?? null } }, 0o600);
4726
4774
  appendEvent(realHome, { kind: stopReceipt.exited ? "restarted" : "stop-refused", data: { phase: "restart-stop", signal: stopReceipt.signal, waitedMs: stopReceipt.waitedMs, stillRunning: stopReceipt.stillRunning ?? [] } });
4727
4775
  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")}`);
4728
4776
  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}`); }
@@ -4731,7 +4779,7 @@ export function startInstanceSession(home, o = {}) {
4731
4779
  const startedAt = new Date().toISOString();
4732
4780
  const id = randomUUID();
4733
4781
  checkRoots();
4734
- const executionCommand = nativeRecordCommand(command, realHome, launchPlan?.runtime || runtime);
4782
+ const executionCommand = nativeRecordCommand(command, realHome, launchPlan?.harness || harness);
4735
4783
  const completedCommand = `${executionCommand}; oats_start_status=$?; printf '%s\\n' ${shq(id)} > ${shq(exitedPath)}`;
4736
4784
  let reused = "new";
4737
4785
  if (backend === "herdr") {
@@ -4998,7 +5046,7 @@ function preserveRetirementWork(observation, meta, instance) {
4998
5046
  const staging = mkdtempSync(join(recoveryRoot, `.${instance}-`));
4999
5047
  const recovery = join(recoveryRoot, basename(staging).slice(1));
5000
5048
  try {
5001
- // A home-only change (notes, runtime files, credentials) needs a home
5049
+ // A home-only change (notes, harness files, credentials) needs a home
5002
5050
  // snapshot, not another copy of an otherwise disposable clean worktree.
5003
5051
  // In-progress Git operations retain the full standalone recovery even
5004
5052
  // when porcelain status has no changed paths.
@@ -5093,7 +5141,7 @@ process.exitCode = completeDeferredRetirement(JSON.parse(process.env.OATS_RETIRE
5093
5141
  /** Self-retire (aweb-abep): persist intent, then hand the retirement to a
5094
5142
  * detached process that runs it as an ORDINARY external retirement after the
5095
5143
  * caller's window has died. Nothing destructive happens in the caller: no
5096
- * inspection, no hooks, no removal — the runtime is still alive, and the
5144
+ * inspection, no hooks, no removal — the harness is still alive, and the
5097
5145
  * quiesce rule stays intact. The child owns its own process group so the
5098
5146
  * tmux window kill (SIGHUP to the pane's group) cannot take it down, and the
5099
5147
  * caller's instance env is stripped so the child is an external operator,
@@ -5293,19 +5341,19 @@ export function retireInstance(root, name, o = {}) {
5293
5341
  const directory = meta.work === "directory";
5294
5342
  const isWorktree = !directory && (meta.work === "worktree" ||
5295
5343
  (existsSync(workPath) && !lstatSync(workPath).isSymbolicLink()));
5296
- // A live runtime cannot establish a stable final work inspection of itself,
5344
+ // A live harness cannot establish a stable final work inspection of itself,
5297
5345
  // so self-retire never inspects, runs hooks, or removes anything here. It
5298
5346
  // persists the intent and hands the whole retirement to a detached process
5299
- // that runs it as an ordinary EXTERNAL retirement once the runtime is gone
5347
+ // that runs it as an ordinary EXTERNAL retirement once the harness is gone
5300
5348
  // (aweb-abep). `--keep-dir` keeps the old in-process path: nothing to inspect.
5301
5349
  if (self && (!o.keepDir || meta.sessionTarget)) {
5302
5350
  return scheduleDeferredSelfRetirement(root, found, name, o, session);
5303
5351
  }
5304
5352
  // First inspection is non-destructive. Only after it succeeds may OATS quiesce
5305
- // the managed runtime; recovery copying never races a live managed Pi.
5353
+ // the managed harness; recovery copying never races a live managed Pi.
5306
5354
  const branchDeletion = { delete: !!(o.deleteBranch || quarantine), repo: meta.repo, branch: meta.branch };
5307
5355
  const initialObservation = inspectRetirementWork(found.home, workPath, isWorktree, { branchDeletion, directory });
5308
- // Runtime identity is destructive authority. The mutable child metadata may
5356
+ // Harness identity is destructive authority. The mutable child metadata may
5309
5357
  // describe it for humans, but only the independent baseline can authorize the
5310
5358
  // endpoint that proves quiescence.
5311
5359
  let runtimeAuthority;