@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.
@@ -0,0 +1,24 @@
1
+ /** lib/deprecation.mjs — the one deprecated-name warning a command answers with.
2
+ *
3
+ * runtime → harness (0.27.0, lead decision call 6): persisted inputs and CLI flags
4
+ * written before 0.27.0 keep working — read either, write new — and every read of
5
+ * the old spelling is noted here. A command answers with ONE warning naming what
6
+ * it read (the JSON envelope's `warnings[]`, or stderr in text mode), never more.
7
+ */
8
+ const sources = new Set();
9
+
10
+ /** Note one read of `runtime` where `harness` is meant; `where` names the key's home. */
11
+ export function noteRuntimeName(where) { sources.add(where); }
12
+
13
+ /** The command's one `deprecated-runtime-name` warning, or null when nothing old was read. */
14
+ export function runtimeNameWarning() {
15
+ if (!sources.size) return null;
16
+ const list = [...sources];
17
+ return {
18
+ code: "deprecated-runtime-name", key: "runtime", replacement: "harness", sources: list,
19
+ message: `\`runtime\` was renamed to \`harness\` in 0.27.0; the old name is still read here (${list.join("; ")}) and a later release drops it`,
20
+ };
21
+ }
22
+
23
+ /** For tests: forget what was noted. */
24
+ export function resetRuntimeNames() { sources.clear(); }
@@ -14,7 +14,7 @@ import { spawnSync } from "node:child_process";
14
14
  import { accessSync, constants as fsConstants, existsSync, readFileSync, realpathSync, statSync } from "node:fs";
15
15
  import { delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
16
16
  import { fileURLToPath } from "node:url";
17
- import { capabilityManifests, instanceSoulDir, manifestOperations, parseYamlNested, servedIdentityOf, teamEnv, withConfigFile } from "./core.mjs";
17
+ import { capabilityManifests, instanceSoulDir, manifestOperations, parseYamlNested, servedIdentityOf, teamEnv, upgradeHomeMeta, withConfigFile } from "./core.mjs";
18
18
  import { discoverOrStandalone, findSoulEntry, liveTeams, prepareInstance } from "./instance-resolution.mjs";
19
19
  import { declaredSettings } from "./capability-contract.mjs";
20
20
  import { kernelCompatibility, teamLabelsOf, teamsOf } from "./resolve.mjs";
@@ -75,6 +75,7 @@ function readTextCapped(file, cap = 200_000) {
75
75
  * messaging provider (a non-messaging operation) gets the record at no remote cost. */
76
76
  export async function homeTarget(home, meta, { remoteOptions, discover = true, live: readLive = true } = {}) {
77
77
  const realHome = real(home);
78
+ meta = upgradeHomeMeta(meta, realHome);
78
79
  // Only the home's own module copies: without <home>/.oats/modules the kernel's
79
80
  // manifest lookup would fall through to a classic config chain — never here.
80
81
  let manifests = {}; const loadProblems = [];
@@ -190,7 +191,7 @@ function capabilityRows(t) {
190
191
  function soulRow(t) {
191
192
  const s = t.soul, def = obj(s.definition) ? s.definition : {};
192
193
  return { soulsApi: SOULS_API, name: s.name, repoKey: s.repoKey, commit: s.commit, team: s.team, kind: s.external === true ? "external" : s.external === false ? "member" : null, path: s.path,
193
- description: def.description ?? null, work: def.work ?? null, runtime: def.runtime ?? null, model: def.model ?? null,
194
+ description: def.description ?? null, work: def.work ?? null, harness: def.harness ?? null, model: def.model ?? null,
194
195
  declarations: declarationsOf(s.definition), declarationProblems: s.problems,
195
196
  instructions: s.soulDir ? readTextCapped(join(s.soulDir, "AGENTS.md")) : null };
196
197
  }
@@ -210,7 +211,7 @@ export function inspectDocument(t, { kernel }) {
210
211
  // live for a home (`teamsSource: "recorded"` when its workspace could not be read now).
211
212
  teams: t.teams ?? null, teamsSource: t.teamsSource ?? null,
212
213
  knowledge: kcap ? { provider: kcap.id, version: kcap.version, operations: kcap.operations.map((o) => ({ name: o.name, kind: o.kind, available: o.available, reason: o.reason })) } : { provider: null, version: null, operations: [] },
213
- instance: m ? { home: t.home, instance: m.instance, agent: m.agent, runtime: m.runtime || null, model: m.model ?? null, yolo: m.yolo ?? null, launched: !!m.launched, createdAt: m.createdAt || null,
214
+ instance: m ? { home: t.home, instance: m.instance, agent: m.agent, harness: m.harness || null, model: m.model ?? null, yolo: m.yolo ?? null, launched: !!m.launched, createdAt: m.createdAt || null,
214
215
  resolution: m.workspace?.resolution ?? null, soulDir: t.soul.soulDir, instructions: { ...readTextCapped(join(t.home, "AGENTS.md")), sources: m.instructions || [] } } : null,
215
216
  ...(m ? { identity: servedIdentityOf(m) } : {}),
216
217
  problems: [...t.soul.problems, ...(t.discoveryError ? [t.discoveryError] : []),
@@ -1,5 +1,5 @@
1
1
  /** Kill a detached child's whole process group (e.g. git + its remote helper /
2
- * ssh, or a runtime probe + what it forked). ONLY for a child that was actually
2
+ * ssh, or a harness probe + what it forked). ONLY for a child that was actually
3
3
  * spawned: a failed spawn (ENOENT) reports `pid: 0`, and `process.kill(-0)` /
4
4
  * `process.kill(0)` address the CALLER's own process group — the operator's
5
5
  * shell, tmux session or Desktop backend. Returns whether anything was signalled. */
package/lib/remote.mjs CHANGED
@@ -581,7 +581,7 @@ function posixNormalize(p) {
581
581
  }
582
582
  /**
583
583
  * The ONE symlink an OATS tree may carry (decision 13): a `CLAUDE.md` whose target
584
- * is `AGENTS.md` beside it — the Claude-runtime alias of the canonical
584
+ * is `AGENTS.md` beside it — the Claude-harness alias of the canonical
585
585
  * instructions. Souls carry it at their root; a capability's agents/<name>/ dirs
586
586
  * carry it too. Anything else stays refused. The rule is on the PATH (any depth,
587
587
  * basename CLAUDE.md); fetchRemoteTree still enforces the target constraint
package/lib/schedule.mjs CHANGED
@@ -28,12 +28,13 @@ import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
28
28
  import { fileURLToPath } from "node:url";
29
29
  import { Cron } from "croner";
30
30
  import { loadLocal } from "./workspace.mjs";
31
+ import { noteRuntimeName } from "./deprecation.mjs";
31
32
  import { RESERVED_LAUNCH_ENV, MAX_INSTANCE_NAME, findAgent, findInstanceHomes, inspectInstanceSession, inputInstanceSession, startInstanceSession, retirePendingMarkerPath } from "./core.mjs";
32
33
 
33
34
  export const SCHEDULE_FILE = "oats-schedules.json";
34
35
  export const SCHEDULE_API = 2;
35
36
  const ID_RE = /^[a-z0-9-]{1,40}$/;
36
- const RUNTIMES = new Set(["pi", "claude", "codex"]);
37
+ const HARNESSES = new Set(["pi", "claude", "codex"]);
37
38
  const BACKENDS = new Set(["tmux", "herdr"]);
38
39
  const MESSAGE_MAX = 256 * 1024;
39
40
  const OATS_BIN = join(dirname(fileURLToPath(import.meta.url)), "..", "bin", "oats.mjs");
@@ -58,6 +59,10 @@ export function scheduleExecutionStatus(def, intent, lock) {
58
59
  /** The definition keys of a captured (versioned) schedule. That path was removed in 0.26. */
59
60
  export const CAPTURED_SCHEDULE_KEYS = Object.freeze(["definitionVersion", "recurrencePolicy", "execution", "preparation"]);
60
61
  const CAPTURED_SCHEDULE_REMOVED = "captured schedules are refused (the captured/portable path was removed in 0.26); remove this job, or re-add it without definitionVersion, recurrencePolicy, execution and preparation";
62
+ /** runtime → harness (0.27.0, lead call 6: read either, write new): a definition's
63
+ * `runtime` is its `harness`. Stored jobs are read with the new name (readDefinitions)
64
+ * and the next write stores it; a job naming both, differently, is invalid on its own. */
65
+ export const HARNESS_CONFLICT = "harness and runtime (its pre-0.27 name) disagree: keep harness: and remove runtime:";
61
66
  export function shapeError(def) {
62
67
  if (!def || typeof def !== "object" || Array.isArray(def)) return "definition is not an object";
63
68
  if (!["spawn", "command", "wake", "operation"].includes(def.kind)) return `kind ${JSON.stringify(def.kind)} is not spawn, command, wake or operation`;
@@ -69,6 +74,7 @@ export function shapeError(def) {
69
74
  // A stored captured (versioned) definition is never evaluated or run (removed in 0.26): it reads
70
75
  // as invalid, migration required, on that job only.
71
76
  if (CAPTURED_SCHEDULE_KEYS.some((key) => def[key] !== undefined)) return CAPTURED_SCHEDULE_REMOVED;
77
+ if (def.runtime !== undefined && def.harness !== undefined && def.runtime !== def.harness) return HARNESS_CONFLICT;
72
78
  return null;
73
79
  }
74
80
 
@@ -133,6 +139,14 @@ function writeJson(path, value) {
133
139
  }
134
140
  export function readDefinitions(ws) {
135
141
  const doc = readJsonBounded(definitionsPath(ws), { version: 1, jobs: {} }, "definitions").value;
142
+ if (doc.jobs && typeof doc.jobs === "object" && !Array.isArray(doc.jobs)) {
143
+ for (const [id, def] of Object.entries(doc.jobs)) {
144
+ if (!def || typeof def !== "object" || Array.isArray(def) || def.runtime === undefined) continue;
145
+ noteRuntimeName(`schedule ${id} in ${definitionsPath(ws)} (its next save records harness)`);
146
+ if (def.harness === undefined) { const { runtime, ...rest } = def; doc.jobs[id] = { ...rest, harness: runtime }; }
147
+ else if (def.harness === def.runtime) { const { runtime, ...rest } = def; void runtime; doc.jobs[id] = rest; }
148
+ }
149
+ }
136
150
  if (![1, 2].includes(doc.version) || typeof doc.jobs !== "object" || doc.jobs === null || Array.isArray(doc.jobs)) throw scheduleError("E_SCHEDULE_INVALID", `${definitionsPath(ws)} must be {version: 1|2, jobs: {}}`, { field: "file" });
137
151
  return doc;
138
152
  }
@@ -297,7 +311,12 @@ export function validateDefinition(ws, def, { checkAgent = true } = {}) {
297
311
  if (derived.length > MAX_INSTANCE_NAME) throw scheduleError("E_SCHEDULE_INVALID", `${out.purpose !== undefined ? "purpose" : "id"}: runs would be named ${out.agent}-${stem}-<YYYYMMDDHHMM> (${derived.length} characters); instance names are at most ${MAX_INSTANCE_NAME} characters — shorten the ${out.purpose !== undefined ? "purpose" : "schedule id (or set a shorter purpose)"}`, { field: out.purpose !== undefined ? "purpose" : "id" }); }
298
312
  if (typeof def.task !== "string" || !def.task.trim()) throw scheduleError("E_SCHEDULE_INVALID", "task: non-empty text", { field: "task" });
299
313
  out.task = def.task;
300
- if (def.runtime !== undefined) { if (!RUNTIMES.has(def.runtime)) throw scheduleError("E_SCHEDULE_INVALID", "runtime: pi, claude or codex", { field: "runtime" }); out.runtime = def.runtime; }
314
+ if (def.runtime !== undefined) {
315
+ if (def.harness !== undefined && def.harness !== def.runtime) throw scheduleError("E_SCHEDULE_INVALID", HARNESS_CONFLICT, { field: "runtime" });
316
+ noteRuntimeName(`schedule ${id}: runtime (written as harness)`);
317
+ def = { ...def, harness: def.runtime };
318
+ }
319
+ if (def.harness !== undefined) { if (!HARNESSES.has(def.harness)) throw scheduleError("E_SCHEDULE_INVALID", "harness: pi, claude or codex", { field: "harness" }); out.harness = def.harness; }
301
320
  if (def.model !== undefined) { if (typeof def.model !== "string") throw scheduleError("E_SCHEDULE_INVALID", "model: string", { field: "model" }); out.model = def.model; }
302
321
  if (def.yolo !== undefined) { if (typeof def.yolo !== "boolean") throw scheduleError("E_SCHEDULE_INVALID", "yolo: boolean", { field: "yolo" }); out.yolo = def.yolo; }
303
322
  if (def.wake !== undefined) {
@@ -352,13 +371,13 @@ export function nextRunAfter(def, from) { const n = cronOf(def).nextRun(from); r
352
371
  /** Due exactly at this minute: the first run after (minute - 1 s) is this minute. */
353
372
  export function dueAt(def, minute) { const n = cronOf(def).nextRun(new Date(minute.getTime() - 1000)); return !!n && n.getTime() === minute.getTime(); }
354
373
  export function purposeSuffix(minute) { return minute.toISOString().slice(0, 16).replace(/[-T:]/g, ""); }
355
- /** Ordering key for admission: when this job last launched a runtime. */
374
+ /** Ordering key for admission: when this job last launched a harness. */
356
375
  export function launchOrderKey(js) { return String(js?.lastLaunchedAt || ""); }
357
376
 
358
377
  // ---------------------------------------------------------- observation
359
378
 
360
379
  /** What a run's instance looks like now. Home gone = ended; a home that is
361
- * retiring is still there (its runtime may be alive pending teardown) and
380
+ * retiring is still there (its harness may be alive pending teardown) and
362
381
  * keeps its slot; present but no running harness = stopped (needs
363
382
  * attention, never removed); running = active; not inspectable = unknown. */
364
383
  export function observeHome(home, io) {
@@ -371,7 +390,7 @@ export function observeHome(home, io) {
371
390
  if (retiring) return { outcome: "active", note: "retiring: home present, teardown pending" };
372
391
  // A shell right after `session start` is that launch's startup phase
373
392
  // until its wrapper writes the exit marker for the launch id; only then
374
- // (or with no start receipt at all) is a shell a finished runtime.
393
+ // (or with no start receipt at all) is a shell a finished harness.
375
394
  if (s.present && s.state === "shell" && startupInProgress(home)) return { outcome: "starting" };
376
395
  return { outcome: "stopped" };
377
396
  } catch (e) { return { outcome: "unknown", error: e.message }; }
@@ -417,7 +436,7 @@ function childEnv() {
417
436
  for (const key of [...RESERVED_LAUNCH_ENV,
418
437
  "OATS_DEPLOYMENT", "OATS_RESOLUTION", "OATS_CAPABILITY", "OATS_LAYER", "OATS_LEVEL", "OATS_META", "OATS_OPERATION",
419
438
  "OATS_REPO", "OATS_BRANCH", "OATS_WORK", "OATS_KIND", "OATS_TASK",
420
- "OATS_RUNTIME", "OATS_PREVIOUS_RUNTIME", "OATS_RETIRE_INTENT",
439
+ "OATS_HARNESS", "OATS_PREVIOUS_HARNESS", "OATS_RUNTIME", "OATS_PREVIOUS_RUNTIME", "OATS_RETIRE_INTENT",
421
440
  "OATS_TEAM_NAME", "OATS_TEAM_ID", "OATS_TEAM_SCOPE",
422
441
  ]) delete env[key];
423
442
  return env;
@@ -443,7 +462,7 @@ export function workspaceSpawnContext(ws) {
443
462
  function spawnViaCli(ws, root, agent, opts, context, io) {
444
463
  if (context.error) throw scheduleError(context.error.code, `this deployment realizes a workspace (${context.dir}) but its oats-local.yaml cannot be read: ${context.error.message}`);
445
464
  const argv = ["spawn", agent.name, "--dir", context.dir, "--agents-root", root, "--purpose", opts.purpose, "--json"];
446
- if (opts.runtime) argv.push("--runtime", opts.runtime);
465
+ if (opts.harness) argv.push("--harness", opts.harness);
447
466
  if (opts.model) argv.push("--model", opts.model);
448
467
  if (opts.backend) argv.push("--backend", opts.backend);
449
468
  if (opts.repo) argv.push("--repo", opts.repo);
@@ -479,7 +498,7 @@ function spawnViaCli(ws, root, agent, opts, context, io) {
479
498
  function launchSpawn(ws, def, minute, io) {
480
499
  const { root, agent } = resolveScheduledAgent(ws, def);
481
500
  const purpose = `${def.purpose || def.id}-${purposeSuffix(minute)}`;
482
- const opts = { task: def.task.trimEnd() + scheduleBlock(def, minute), purpose, runtime: def.runtime, model: def.model, yolo: def.yolo, backend: def.backend, repo: def.repo, launch: io?.noLaunch === true ? false : true };
501
+ const opts = { task: def.task.trimEnd() + scheduleBlock(def, minute), purpose, harness: def.harness, model: def.model, yolo: def.yolo, backend: def.backend, repo: def.repo, launch: io?.noLaunch === true ? false : true };
483
502
  let r;
484
503
  if (io?.spawn) r = io.spawn(root, agent, opts);
485
504
  else r = spawnViaCli(ws, root, agent, opts, workspaceSpawnContext(ws), io);
@@ -561,7 +580,7 @@ function validateActiveExecution(js, lock) {
561
580
  }
562
581
  /** One wake. `startIfStopped` false between due minutes (a harness that
563
582
  * keeps exiting is not restarted every minute); `canStart` false when the
564
- * host bound is reached (a new runtime takes a slot; a delivery does not). */
583
+ * host bound is reached (a new harness takes a slot; a delivery does not). */
565
584
  /** A failure message that reports effects the kernel could not undo or
566
585
  * confirm (spawn compensation's "rollback INCOMPLETE", a quarantined or
567
586
  * retained home): the launch's effects are unconfirmed, never a confirmed
@@ -580,14 +599,14 @@ function performWake(def, io, { startIfStopped = true, canStart = true, reserve
580
599
  // The slot is persisted BEFORE the start call and kept on ANY start
581
600
  // exception: an error code does not prove nothing was allocated (core
582
601
  // can refuse while recording, after the session exists). The next
583
- // observation releases it once the runtime is proven stopped or absent;
602
+ // observation releases it once the harness is proven stopped or absent;
584
603
  // at worst a refused start occupies a slot for one tick.
585
604
  if (reserve && !reserve()) return { kind: "wake", action: "skipped", reason: "the job's slot is held by another process; delivery pending", pending: true };
586
605
  try {
587
606
  const r = (io?.start || startInstanceSession)(def.home);
588
- return { kind: "wake", action: "started", instance: r.instance, target: r.target, home: def.home, incarnation: incarnationOfHome(def.home), reason: "session was stopped; the message is pending until the session is active", pending: true, startedRuntime: true };
607
+ return { kind: "wake", action: "started", instance: r.instance, target: r.target, home: def.home, incarnation: incarnationOfHome(def.home), reason: "session was stopped; the message is pending until the session is active", pending: true, startedHarness: true };
589
608
  } catch (e) {
590
- return { kind: "wake", action: "skipped", reason: `start did not complete (${e.code || "error"}): ${e.message}; the slot is kept until the session is observed stopped or absent`, error: e.message, errorCode: e.code, pending: true, startedRuntime: "unconfirmed" };
609
+ return { kind: "wake", action: "skipped", reason: `start did not complete (${e.code || "error"}): ${e.message}; the slot is kept until the session is observed stopped or absent`, error: e.message, errorCode: e.code, pending: true, startedHarness: "unconfirmed" };
591
610
  }
592
611
  }
593
612
  try {
@@ -602,9 +621,9 @@ function performWake(def, io, { startIfStopped = true, canStart = true, reserve
602
621
 
603
622
  /** Observe the previous run of one job. The tracked identity is what was
604
623
  * launched (the lock's or lastRun's home), never the current definition,
605
- * so an edit cannot make a slot forget its runtime. A wake slot is held
606
- * while its started runtime is active, starting, unknown or retiring and
607
- * released when that runtime is proven stopped (the start receipt's exit
624
+ * so an edit cannot make a slot forget its harness. A wake slot is held
625
+ * while its started harness is active, starting, unknown or retiring and
626
+ * released when that harness is proven stopped (the start receipt's exit
608
627
  * marker) or its home is gone: a persistent home outliving its process
609
628
  * does not keep a slot. */
610
629
  function observePrevious(ws, id, def, js, io, now, { mutate }) {
@@ -624,7 +643,7 @@ function observePrevious(ws, id, def, js, io, now, { mutate }) {
624
643
  if (seen.outcome === "ended" || seen.outcome === "stopped") {
625
644
  releaseJobLock(ws, id);
626
645
  if (seen.outcome === "ended") delete js.pendingWake;
627
- if (lr) js.lastRun = { ...lr, ...(seen.outcome === "ended" ? { endedAt: now.toISOString() } : { stoppedAt: now.toISOString() }), note: seen.outcome === "ended" ? "started runtime ended" : "started runtime stopped; slot released" };
646
+ if (lr) js.lastRun = { ...lr, ...(seen.outcome === "ended" ? { endedAt: now.toISOString() } : { stoppedAt: now.toISOString() }), note: seen.outcome === "ended" ? "started harness ended" : "started harness stopped; slot released" };
628
647
  }
629
648
  return;
630
649
  }
@@ -654,7 +673,7 @@ export function tickWorkspace(ws, { now = new Date(), io, reg, wsList, dryRun =
654
673
  // `candidates` (the host tick) means the host already observed every scope.
655
674
  if (!candidates) for (const [id, def] of Object.entries(defs.jobs)) observePrevious(ws, id, def, stateOf(id), io, now, { mutate: !dryRun });
656
675
  if (observeOnly) { if (!dryRun) writeState(ws, st); return considered; }
657
- // Least recently LAUNCHED first: only an actual runtime launch counts, a
676
+ // Least recently LAUNCHED first: only an actual harness launch counts, a
658
677
  // skipped or pending job keeps its place at the front of the line.
659
678
  const order = (candidates || Object.keys(defs.jobs)).filter((id) => Object.prototype.hasOwnProperty.call(defs.jobs, id) && (!only || id === only)).sort((a, b) => launchOrderKey(st.jobs[a]).localeCompare(launchOrderKey(st.jobs[b])) || a.localeCompare(b));
660
679
  for (const id of order) {
@@ -671,7 +690,7 @@ export function tickWorkspace(ws, { now = new Date(), io, reg, wsList, dryRun =
671
690
  const held = !!jobLockInfo(ws, id);
672
691
  const canStart = held || liveLocks(wsAll) < bound;
673
692
  const run = performWake(def, io, { startIfStopped: dueNow, canStart, reserve: () => held || acquireJobLock(ws, id, { scheduledFor: js.pendingWake.scheduledFor, wake: true, home: def.home }) });
674
- if (run.startedRuntime) js.lastLaunchedAt = now.toISOString();
693
+ if (run.startedHarness) js.lastLaunchedAt = now.toISOString();
675
694
  if (!existsSync(def.home)) releaseJobLock(ws, id);
676
695
  js.lastRun = { ...(js.lastRun || {}), ...run, outcome: run.action, completedPending: run.action === "delivered", pendingSince: js.pendingWake.scheduledFor, startedAt: js.lastRun?.startedAt || now.toISOString() };
677
696
  if (!run.pending) delete js.pendingWake;
@@ -700,7 +719,7 @@ export function tickWorkspace(ws, { now = new Date(), io, reg, wsList, dryRun =
700
719
  // one does, and the slot is held (the job lock) until that home ends.
701
720
  const holdsSlot = !!jobLockInfo(ws, id);
702
721
  const run = performWake(def, io, { startIfStopped: true, canStart: holdsSlot || liveLocks(wsAll) < bound, reserve: () => holdsSlot || acquireJobLock(ws, id, { scheduledFor: minute.toISOString(), wake: true, home: def.home }) });
703
- if (run.startedRuntime) js.lastLaunchedAt = now.toISOString();
722
+ if (run.startedHarness) js.lastLaunchedAt = now.toISOString();
704
723
  if (!existsSync(def.home)) releaseJobLock(ws, id);
705
724
  if (run.pending) js.pendingWake = { scheduledFor: minute.toISOString() }; else delete js.pendingWake;
706
725
  js.lastRun = { scheduledFor: minute.toISOString(), startedAt: now.toISOString(), home: def.home, ...run, outcome: run.action, ...(run.instance ? { instance: run.instance } : {}) };
@@ -856,7 +875,7 @@ function incarnationOfHome(home) { try { const m = JSON.parse(readFileSync(join(
856
875
  * transcript: there is no reader behind this block. */
857
876
  function sessionProvenanceOf(r) {
858
877
  if (!r || typeof r !== "object") return null;
859
- const delivery = r.startedRuntime ? "launched" : r.action === "delivered" || r.outcome === "delivered" || r.completedPending ? "delivered-active" : r.launched === true || r.instance ? "launched" : "none";
878
+ const delivery = r.startedHarness || r.startedRuntime ? "launched" : r.action === "delivered" || r.outcome === "delivered" || r.completedPending ? "delivered-active" : r.launched === true || r.instance ? "launched" : "none";
860
879
  return { instance: typeof r.instance === "string" ? r.instance : null, home: typeof r.home === "string" ? r.home : null, incarnation: typeof r.incarnation === "string" ? r.incarnation : null, server: typeof r.server === "string" ? r.server : null, delivery };
861
880
  }
862
881
  /** Read-time projection of one job's stored history: bounded to
package/lib/servers.mjs CHANGED
@@ -22,6 +22,7 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync
22
22
  import { homedir } from "node:os";
23
23
  import { join, resolve } from "node:path";
24
24
  import { parseStrictJson } from "./canonical-json.mjs";
25
+ import { noteRuntimeName } from "./deprecation.mjs";
25
26
 
26
27
  const OATS_HOME_DIR = () => process.env.OATS_HOME_DIR || join(homedir(), ".oats");
27
28
  export const SERVERS_FILE = () => join(OATS_HOME_DIR(), "servers.json");
@@ -224,53 +225,64 @@ export function checkRemote(target, io = {}) {
224
225
  }
225
226
  // What the remote kernel advertises it can launch. A probe without these
226
227
  // fields is a 0.22.1-class kernel: pi and claude only, no session backend
227
- // choice, no launch options; nothing newer may be requested of it.
228
+ // choice, no launch options; nothing newer may be requested of it. The
229
+ // harness list is named `harnesses` since 0.27.0; an older kernel names the
230
+ // same list `runtimes`, and is read in its own vocabulary (lead call 6).
228
231
  const list = (k, fallback) => (Array.isArray(probe[k]) ? probe[k].map(String) : fallback);
232
+ const harnessKey = Array.isArray(probe.harnesses) ? "harnesses" : "runtimes";
229
233
  return {
230
234
  version: probe.version, schemaVersion: 1, desktopApi: 1,
231
- runtimes: list("runtimes", ["pi", "claude"]),
235
+ harnesses: list(harnessKey, ["pi", "claude"]),
232
236
  sessionBackends: list("sessionBackends", []),
233
237
  launchOptions: list("launchOptions", []),
234
238
  remote: list("remote", []),
235
239
  features: list("features", []),
236
240
  operationsApi: [1, 2].includes(probe.operationsApi) ? probe.operationsApi : null,
237
241
  scheduleApi: [1, 2].includes(probe.scheduleApi) ? probe.scheduleApi : null,
238
- advertised: Array.isArray(probe.runtimes),
242
+ advertised: Array.isArray(probe[harnessKey]),
239
243
  };
240
244
  }
241
245
 
242
246
  /** Refuse, before any mutation, a spawn that asks the remote kernel for a
243
- * runtime, session backend or launch option it does not advertise. The
244
- * effective runtime is the --runtime flag or the soul's default from the
247
+ * harness, session backend or launch option it does not advertise. The
248
+ * effective harness is the --harness flag or the soul's default from the
245
249
  * remote roster, resolved THERE, since the local roster says nothing about
246
250
  * that host's souls. Desktop's local support check cannot prove remote
247
251
  * support; this is the proof. */
252
+ /** The client speaks the host's vocabulary (0.27.0, lead call 6): a host that does not
253
+ * advertise the harness feature is asked with `--runtime`, the name it reads. */
254
+ export function hostHarnessArgs(remote, args) {
255
+ if ((remote?.features || []).includes("harness")) return args;
256
+ return args.map((a) => (a === "--harness" ? "--runtime" : a.startsWith("--harness=") ? `--runtime=${a.slice("--harness=".length)}` : a));
257
+ }
248
258
  export function checkRemoteSupport(remote, target, oatsArgs, roster) {
249
259
  const flagOf = (name) => { const i = oatsArgs.indexOf(name); return i >= 0 && oatsArgs[i + 1] && !oatsArgs[i + 1].startsWith("--") ? oatsArgs[i + 1] : undefined; };
250
260
  const agent = oatsArgs.find((a) => !a.startsWith("--"));
251
261
  const soul = (roster?.agents || []).find((a) => a.name === agent);
252
- // The effective runtime is the flag, else the soul's default as the remote
253
- // roster reports it. A soul the roster does not list with a runtime (a
262
+ // The effective harness is the flag, else the soul's default as the remote
263
+ // roster reports it. A soul the roster does not list with a harness (a
254
264
  // capability-defined agent, or one with no live instance) has no default
255
265
  // this side can establish: the remote kernel validates its own default at
256
266
  // spawn, and nothing is asserted here about it.
257
267
  const namedConfig = oatsArgs.includes("--launch-config");
258
- const runtime = flagOf("--runtime") || (namedConfig ? undefined : soul?.runtime);
268
+ // --runtime is the pre-0.27 name of --harness; a roster row from a host before 0.27 says `runtime`.
269
+ const chosen = flagOf("--harness") || flagOf("--runtime");
270
+ const harness = chosen || (namedConfig ? undefined : soul?.harness ?? soul?.runtime);
259
271
  // What the message may claim depends on what was established: an
260
272
  // advertising remote said what it supports; a silent one (before 0.22.2)
261
273
  // said nothing, and only pi and claude are assumed of it.
262
274
  const supports = remote.advertised
263
- ? `it advertises runtimes ${remote.runtimes.join(", ")}${remote.sessionBackends.length ? `, session backends ${remote.sessionBackends.join(", ")}` : ", no session backend choice"}${remote.launchOptions.length ? `, launch options ${remote.launchOptions.join(", ")}` : ", no launch options"}`
264
- : `it does not advertise what it supports (kernels before 0.22.2 do not), so only pi and claude on tmux with no launch options are assumed of it; upgrade it there to use more`;
275
+ ? `it advertises harnesses ${remote.harnesses.join(", ")}${remote.sessionBackends.length ? `, session backends ${remote.sessionBackends.join(", ")}` : ", no session backend choice"}${remote.launchOptions.length ? `, launch options ${remote.launchOptions.join(", ")}` : ", no launch options"}`
276
+ : `it does not advertise its harnesses (kernels before 0.22.2 do not), so only pi and claude are assumed of it${remote.sessionBackends.length ? `, with session backends ${remote.sessionBackends.join(", ")}` : " on tmux"}${remote.launchOptions.length ? ` and launch options ${remote.launchOptions.join(", ")}` : " with no launch options"}; upgrade it there to use more`;
265
277
  const refuse = (what) => { throw serverError("E_REMOTE_INCOMPATIBLE", `remote oats ${remote.version} at ${target.sshHost}: ${what} was not established as supported there (${supports})`); };
266
278
  if (namedConfig && !remote.features.includes("launch-config")) refuse("a named launch configuration (the host must advertise launch-config)");
267
- if (runtime && !remote.runtimes.includes(runtime)) refuse(`runtime ${runtime}${flagOf("--runtime") ? "" : ` (the default of soul ${agent} there)`}`);
279
+ if (harness && !remote.harnesses.includes(harness)) refuse(`harness ${harness}${chosen ? "" : ` (the default of soul ${agent} there)`}`);
268
280
  // A wake schedule at spawn is saved by the host: it must advertise schedules.
269
281
  if (["--wake-json", "--wake-every", "--wake-cron"].some((f) => oatsArgs.includes(f)) && !remote.features.includes("schedule")) refuse("a wake schedule at spawn (the host must advertise the schedule feature)");
270
282
  const backend = flagOf("--backend");
271
283
  if (backend && !remote.sessionBackends.includes(backend)) refuse(`session backend ${backend}`);
272
284
  if (oatsArgs.includes("--yolo") && !remote.launchOptions.includes("yolo")) refuse("the yolo launch option");
273
- return { runtime, backend, yolo: oatsArgs.includes("--yolo") };
285
+ return { harness, backend, yolo: oatsArgs.includes("--yolo") };
274
286
  }
275
287
 
276
288
  /** The remote kernel version that carries `oats session` (inspect, input,
@@ -404,13 +416,13 @@ export function routeCommand(serverId, cmd, oatsArgs, io = {}) {
404
416
  const ii = oatsArgs.indexOf("--instance");
405
417
  const explicitName = ii >= 0 && oatsArgs[ii + 1] && !oatsArgs[ii + 1].startsWith("--") ? oatsArgs[ii + 1] : undefined;
406
418
  if (explicitName && readSnapshot(serverId, explicitName)) throw serverError("E_ROUTE_EXISTS", `a saved route for ${explicitName} through server ${serverId} already exists (${readSnapshot(serverId, explicitName).home}); retire it (oats retire ${explicitName} --server ${serverId}) or drop it (oats server forget ${serverId} --instance ${explicitName}) before spawning that name again`);
407
- // The remote roster: the soul's runtime default for the support check,
419
+ // The remote roster: the soul's harness default for the support check,
408
420
  // and the remote agents root for the snapshot (the kernel's spawn result
409
421
  // does not carry it, and guessing it from the workspace would be wrong).
410
422
  const status = runRemote(target, json(withScope(["status"])), io).envelope;
411
423
  if (!status.ok) return { envelope: status, stderr: "" };
412
424
  checkRemoteSupport(remote, target, oatsArgs, status.result);
413
- const { envelope, stderr } = runRemote(target, json(withScope(["spawn", ...oatsArgs])), io);
425
+ const { envelope, stderr } = runRemote(target, json(withScope(["spawn", ...hostHarnessArgs(remote, oatsArgs)])), io);
414
426
  if (envelope.ok && envelope.result?.instance) {
415
427
  // A generated name can still collide with a saved route of another
416
428
  // soul on the same host (dev --purpose foo-1 vs dev-foo --purpose 1).
@@ -576,7 +588,7 @@ export function rosterGroups({ server, io = {} } = {}) {
576
588
  g.probe = { ok: true };
577
589
  g.agentsRoot = status.result.root;
578
590
  for (const a of status.result.agents || []) {
579
- g.souls.push({ name: a.name, runtime: a.runtime, work: a.work, backend: a.backend, description: a.description, agentsRoot: status.result.root });
591
+ g.souls.push({ name: a.name, harness: a.harness ?? a.runtime, work: a.work, backend: a.backend, description: a.description, agentsRoot: status.result.root });
580
592
  // A failed deferred self-retirement needs an operator: it rides with
581
593
  // the group, named by agent and instance, as `oats status` prints it.
582
594
  for (const f of a.retireFailures || []) g.retireFailures.push({ agent: a.name, ...f });
@@ -584,7 +596,7 @@ export function rosterGroups({ server, io = {} } = {}) {
584
596
  const snap = routeOf(i);
585
597
  g.instances.push({
586
598
  server: g.server, instance: i.instance, agent: a.name, home: i.home || snap?.home, agentsRoot: status.result.root,
587
- runtime: i.runtime || null, backend: i.sessionTarget?.backend || (i.tmux ? "tmux" : null),
599
+ harness: i.harness || i.runtime || null, backend: i.sessionTarget?.backend || (i.tmux ? "tmux" : null),
588
600
  ...(i.sessionTarget ? { sessionTarget: i.sessionTarget } : {}), ...(i.tmux ? { tmux: i.tmux } : {}),
589
601
  running: typeof i.running === "boolean" ? i.running : null, ...(i.runtimeError ? { runtimeError: i.runtimeError } : {}),
590
602
  retirePending: !!i.retirePending, rollbackIncomplete: !!i.rollbackIncomplete, savedRoute: !!snap, missingRemotely: false,
@@ -597,7 +609,7 @@ export function rosterGroups({ server, io = {} } = {}) {
597
609
  // instance is gone there (retired, or its home removed) and the route is
598
610
  // stale; with a failed probe nothing is known. Same keys as remote rows.
599
611
  for (const snap of bySnapshot.values()) {
600
- g.instances.push({ server: g.server, instance: snap.instance, agent: snap.agent, home: snap.home, agentsRoot: snap.agentsRoot, runtime: snap.runtime || null, backend: null, running: null, retirePending: false, rollbackIncomplete: false, savedRoute: true, missingRemotely: g.probe?.ok === true });
612
+ g.instances.push({ server: g.server, instance: snap.instance, agent: snap.agent, home: snap.home, agentsRoot: snap.agentsRoot, harness: snap.harness || snap.runtime || null, backend: null, running: null, retirePending: false, rollbackIncomplete: false, savedRoute: true, missingRemotely: g.probe?.ok === true });
601
613
  }
602
614
  delete g._snapshots;
603
615
  }
@@ -667,15 +679,15 @@ export function restartRemote(serverId, choices = {}, io = {}) {
667
679
  return launchRemoteSession(serverId, "restart", choices, io);
668
680
  }
669
681
 
670
- function remoteLaunchArgs({ launchConfig, runtime, model, yolo } = {}) {
682
+ function remoteLaunchArgs({ launchConfig, harness, model, yolo } = {}) {
671
683
  const args = [];
672
684
  if (launchConfig !== undefined) {
673
685
  if (typeof launchConfig !== "string" || !/^[a-z0-9][a-z0-9._-]{0,63}$/i.test(launchConfig)) throw serverError("E_BAD_ARGS", "invalid launch configuration name");
674
686
  args.push("--launch-config", launchConfig);
675
687
  }
676
- if (runtime !== undefined) {
677
- if (!["pi", "claude", "codex"].includes(runtime)) throw serverError("E_BAD_ARGS", "runtime must be pi, claude or codex");
678
- args.push("--runtime", runtime);
688
+ if (harness !== undefined) {
689
+ if (!["pi", "claude", "codex"].includes(harness)) throw serverError("E_BAD_ARGS", "harness must be pi, claude or codex");
690
+ args.push("--harness", harness);
679
691
  }
680
692
  if (model !== undefined && model !== null && model !== "") {
681
693
  if (typeof model !== "string" || model.startsWith("-") || model.includes("\0")) throw serverError("E_BAD_ARGS", "invalid model name");
@@ -693,7 +705,7 @@ function requireRemoteFeature(remote, target, feature) {
693
705
  }
694
706
 
695
707
  function launchRemoteSession(serverId, action, choices, io) {
696
- const { instance, home, launchConfig, runtime, yolo } = choices;
708
+ const { instance, home, launchConfig, harness, yolo } = choices;
697
709
  const choiceArgs = remoteLaunchArgs(choices);
698
710
  const route = resolveRoute(serverId, { instance, home }, `session ${action}`);
699
711
  const remote = requireSessionRemote(route.target, io);
@@ -701,8 +713,8 @@ function launchRemoteSession(serverId, action, choices, io) {
701
713
  throw serverError("E_REMOTE_INCOMPATIBLE", `remote oats ${remote.version} at ${route.target.sshHost} does not advertise session-start (kernels from ${SESSION_START_REMOTE_VERSION} do); upgrade it there, or start the instance on that host`);
702
714
  }
703
715
  if (action === "restart") requireRemoteFeature(remote, route.target, "session-restart");
704
- if (launchConfig !== undefined || runtime !== undefined || yolo !== undefined) requireRemoteFeature(remote, route.target, "launch-config");
705
- const args = ["session", action, "--home", route.home, ...choiceArgs, "--json"];
716
+ if (launchConfig !== undefined || harness !== undefined || yolo !== undefined) requireRemoteFeature(remote, route.target, "launch-config");
717
+ const args = ["session", action, "--home", route.home, ...hostHarnessArgs(remote, choiceArgs), "--json"];
706
718
  const { envelope, stderr } = runRemote(route.target, args, io);
707
719
  return { envelope: envelope.ok ? { ...envelope, result: { ...envelope.result, server: serverId, instance: instance || route.snapshot?.instance || envelope.result.instance } } : envelope, stderr, route };
708
720
  }
@@ -711,6 +723,15 @@ function launchRemoteSession(serverId, action, choices, io) {
711
723
  * its saved route. Definitions travel on stdin, never as remote file paths or
712
724
  * shell arguments. The host resolves environment references and validates the
713
725
  * definition; this client never substitutes its own environment. */
726
+ /** A launch definition in the host's vocabulary: `harness` for a host with the harness feature,
727
+ * `runtime` for one before 0.27.0. Either spelling is read here; both, disagreeing, are refused. */
728
+ function hostLaunchDefinition(remote, definition) {
729
+ const { harness, runtime, ...rest } = definition; // a disagreeing pair was refused before the probe
730
+ if (runtime !== undefined) noteRuntimeName("runtime in the launch configuration definition (sent as the host's harness key)");
731
+ const value = harness ?? runtime;
732
+ if (value === undefined) return rest;
733
+ return (remote?.features || []).includes("harness") ? { ...rest, harness: value } : { ...rest, runtime: value };
734
+ }
714
735
  export function launchConfigRemote(serverId, options = {}, io = {}) {
715
736
  const { action, name, context, instance, home, soul, agentsRoot, definition, keepEnv } = options;
716
737
  if (!["list", "set", "remove", "preview"].includes(action)) throw serverError("E_BAD_ARGS", "unknown launch configuration action");
@@ -729,6 +750,7 @@ export function launchConfigRemote(serverId, options = {}, io = {}) {
729
750
  if (!definition || typeof definition !== "object" || Array.isArray(definition)) throw serverError("E_BAD_ARGS", "specify a launch configuration object");
730
751
  if (keepEnv !== undefined && typeof keepEnv !== "boolean") throw serverError("E_BAD_ARGS", "keepEnv must be true or false");
731
752
  if (keepEnv && Object.hasOwn(definition, "env")) throw serverError("E_BAD_ARGS", "omit env when preserving the saved environment");
753
+ if (definition.harness !== undefined && definition.runtime !== undefined && definition.harness !== definition.runtime) throw serverError("E_BAD_ARGS", "the definition names harness and runtime (its pre-0.27 name) with different values; nothing was sent");
732
754
  input = Buffer.from(JSON.stringify(definition));
733
755
  args.push("--file", "-");
734
756
  if (keepEnv) args.push("--keep-env");
@@ -749,7 +771,9 @@ export function launchConfigRemote(serverId, options = {}, io = {}) {
749
771
  // stdin belongs only to the set operation, never its preceding version probe.
750
772
  const remote = checkRemote(target, { ...io, input: undefined });
751
773
  requireRemoteFeature(remote, target, "launch-config");
752
- const { envelope, stderr } = runRemote(target, [...args, "--json"], { ...io, input });
774
+ // The host's vocabulary: its harness flag, and the definition's key (read either here).
775
+ if (input !== undefined) input = Buffer.from(JSON.stringify(hostLaunchDefinition(remote, definition)));
776
+ const { envelope, stderr } = runRemote(target, [...hostHarnessArgs(remote, args), "--json"], { ...io, input });
753
777
  return { envelope, stderr, target, ...(route ? { route } : {}) };
754
778
  }
755
779
 
@@ -774,6 +798,14 @@ export function scheduleRemote(serverId, oatsArgs, io = {}) {
774
798
  // refuses them, never forwarded to a remote kernel that might still accept one.
775
799
  const captured = CAPTURED_SCHEDULE_KEYS.filter((key) => Object.hasOwn(spec, key));
776
800
  if (captured.length) throw serverError("E_SCHEDULE_INVALID", `${captured.join(", ")}: captured schedules are refused (the captured/portable path was removed in 0.26); no request was forwarded`);
801
+ // The host's vocabulary for the spawn job's harness (read either here).
802
+ if (Object.hasOwn(spec, "harness") || Object.hasOwn(spec, "runtime")) {
803
+ const { harness, runtime, ...rest } = spec;
804
+ if (harness !== undefined && runtime !== undefined && harness !== runtime) throw serverError("E_SCHEDULE_INVALID", "the schedule names harness and runtime (its pre-0.27 name) with different values; no request was forwarded");
805
+ const value = harness ?? runtime;
806
+ if (runtime !== undefined) noteRuntimeName("runtime in the schedule spec (sent as the host's harness key)");
807
+ oatsArgs = oatsArgs.map((a, i) => (i === indexes[0] + 1 ? JSON.stringify((remote.features || []).includes("harness") ? { ...rest, harness: value } : { ...rest, runtime: value }) : a));
808
+ }
777
809
  }
778
810
  }
779
811
  const args = ["schedule", ...oatsArgs.filter((a) => a !== "--json"), "--dir", target.workspace, "--json"];
package/lib/workspace.mjs CHANGED
@@ -294,6 +294,13 @@ function fromProblems(remote, capabilities, path, problems, { here }) {
294
294
  export function validateLocal(value) {
295
295
  const problems = validateAgainst(schemaFor("local"), value);
296
296
  if (isObject(value) && isObject(value.settings)) for (const [cap, payload] of Object.entries(value.settings)) reservedKeyProblems(payload, `/settings/${pointerKey(cap)}`, problems);
297
+ // runtime → harness (0.27.0, lead call 6): `runtime`, the pre-0.27 name, is still read; both,
298
+ // disagreeing, are refused.
299
+ if (isObject(value) && isObject(value["launch-configs"])) for (const [name, entry] of Object.entries(value["launch-configs"])) {
300
+ if (isObject(entry) && Object.hasOwn(entry, "runtime") && Object.hasOwn(entry, "harness") && entry.runtime !== entry.harness) {
301
+ problems.push({ path: `/launch-configs/${pointerKey(name)}/runtime`, reason: "harness-conflict", message: `harness ${show(entry.harness)} and runtime ${show(entry.runtime)} disagree: \`runtime\` is the pre-0.27 name of \`harness\` — keep one` });
302
+ }
303
+ }
297
304
  return problems;
298
305
  }
299
306
 
@@ -8,7 +8,7 @@
8
8
  },
9
9
  "oats.aweb": {
10
10
  "url": "https://github.com/awebai/oats-aweb.git",
11
- "ref": "v1.13.1",
11
+ "ref": "v1.14.2",
12
12
  "path": "oats-package"
13
13
  },
14
14
  "oats.jira": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awebai/oats",
3
- "version": "0.26.0",
3
+ "version": "0.27.1",
4
4
  "description": "OATS (Open Agent Team Specification) — durable souls, disposable instances, targetable capability packages, and the runtime-neutral oats CLI/kernel.",
5
5
  "keywords": [
6
6
  "agents",
@@ -16,7 +16,9 @@ export function sourceSessionEnvironment(home, base = process.env) {
16
16
  }
17
17
  const recipe = meta?.launch;
18
18
  if (recipe !== undefined) {
19
- if (!recipe || recipe.version !== 1 || !["claude", "pi", "codex"].includes(recipe.runtime)) {
19
+ // Version 1 (a 0.26.0 home) names the harness `runtime`; version 2 `harness`.
20
+ const harness = recipe?.version === 2 ? recipe.harness : recipe?.version === 1 ? recipe.runtime : undefined;
21
+ if (!recipe || !["claude", "pi", "codex"].includes(harness)) {
20
22
  throw new Error("cannot resolve source transcript roots: unsupported recorded launch recipe");
21
23
  }
22
24
  for (const layer of [recipe.hooks?.env, recipe.env]) {
@@ -32,7 +34,7 @@ export function sourceSessionEnvironment(home, base = process.env) {
32
34
  }
33
35
  // Pi also supports a direct session-directory override. Do not certify
34
36
  // default roots when native options direct evidence somewhere else.
35
- if (recipe.runtime === "pi") {
37
+ if (harness === "pi") {
36
38
  const args = recipe.args ?? [];
37
39
  if (!Array.isArray(args) || args.some((a) => typeof a !== "string")) throw new Error("cannot resolve source transcript roots: invalid launch arguments");
38
40
  for (let i = 0; i < args.length; i++) {
@@ -69,13 +71,13 @@ export function nativeDirectory(value, { home = homedir(), cwd = process.cwd(),
69
71
  /** Native execution-side locations, without existence filtering. Unlike a
70
72
  * background observer scan, Claude's native default is exactly ~/.claude,
71
73
  * not every .claude* profile found under an observer's HOME. */
72
- export function nativeLaunchLocations(runtime, { cwd, env = process.env, args = [] } = {}) {
74
+ export function nativeLaunchLocations(harness, { cwd, env = process.env, args = [] } = {}) {
73
75
  const home = env.HOME || homedir();
74
76
  if (!isAbsolute(home)) throw new Error("native launch HOME must be absolute");
75
77
  if (!Array.isArray(args) || args.some(a => typeof a !== "string")) throw new Error("invalid native launch arguments");
76
- if (runtime === "claude") return [join(nativeDirectory(env.CLAUDE_CONFIG_DIR || join(home, ".claude"), { home, cwd }), "projects")];
77
- if (runtime === "codex") return [join(nativeDirectory(env.CODEX_HOME || join(home, ".codex"), { home, cwd }), "sessions")];
78
- if (runtime !== "pi") throw new Error("unsupported native record runtime");
78
+ if (harness === "claude") return [join(nativeDirectory(env.CLAUDE_CONFIG_DIR || join(home, ".claude"), { home, cwd }), "projects")];
79
+ if (harness === "codex") return [join(nativeDirectory(env.CODEX_HOME || join(home, ".codex"), { home, cwd }), "sessions")];
80
+ if (harness !== "pi") throw new Error("unsupported native record harness");
79
81
  let sessionDir = env.PI_CODING_AGENT_SESSION_DIR;
80
82
  for (let i = 0; i < args.length; i++) {
81
83
  const arg = args[i];
@@ -81,7 +81,7 @@ plus `knowledge` / `messaging` / `tasks` slots; `none` empties one). The
81
81
  soul lives in its member repository, which is also what it works on.
82
82
 
83
83
  Runtime, model and permission bypass are not soul fields: they are chosen at
84
- spawn (`--runtime`, `--model`, `--yolo`) or by a host's named launch
84
+ spawn (`--harness`, `--model`, `--yolo`) or by a host's named launch
85
85
  configuration, so the same soul runs on any harness a host provides. Check a
86
86
  soul with `oats spawn <soul> --preview` before committing it.
87
87