@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/bin/oats.mjs +82 -51
- package/capabilities/oats-aweb/bin/oats-aweb.mjs +272 -72
- package/capabilities/oats-aweb/injects/aweb.md +9 -1
- package/capabilities/oats-aweb/lib/binding-wire.mjs +42 -11
- package/capabilities/oats-aweb/oats.json +47 -5
- package/capabilities/oats-aweb/skills/aweb-team-membership/SKILL.md +79 -301
- package/docs/capabilities.md +11 -11
- package/docs/capability-manifest.schema.json +16 -5
- package/docs/configuration.md +2 -2
- package/docs/conventions.md +2 -2
- package/docs/desktop-cli-api.md +70 -13
- package/docs/desktop-instance-start.md +2 -2
- package/docs/first-team.md +1 -1
- package/docs/implementation.md +3 -3
- package/docs/oats-local.schema.json +5 -4
- package/docs/packages.md +2 -2
- package/docs/release-notes/v0.27.0.md +100 -0
- package/docs/release-notes/v0.27.1.md +29 -0
- package/docs/schedules.md +6 -6
- package/docs/servers.md +4 -4
- package/docs/souls-and-instances.md +6 -6
- package/injects/instance-boundary.md +1 -1
- package/lib/core.mjs +255 -207
- package/lib/deprecation.mjs +24 -0
- package/lib/instance-inspect.mjs +4 -3
- package/lib/process-group.mjs +1 -1
- package/lib/remote.mjs +1 -1
- package/lib/schedule.mjs +39 -20
- package/lib/servers.mjs +57 -25
- package/lib/workspace.mjs +7 -0
- package/package-catalog.json +1 -1
- package/package.json +1 -1
- package/packages/record/lib/session-roots.mjs +8 -6
- package/skills/soul-craft/SKILL.md +1 -1
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),
|
|
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
|
|
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
|
|
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
|
|
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)} (
|
|
525
|
-
if (
|
|
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
|
-
|
|
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
|
-
// ----------
|
|
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
|
-
/**
|
|
1164
|
-
* package is NOT a command on PATH: it is registered with the
|
|
1165
|
-
* detection and post-install verification read that
|
|
1166
|
-
* Null-prototype: `
|
|
1167
|
-
* `
|
|
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-
|
|
1170
|
-
export const
|
|
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
|
|
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) => [
|
|
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 =
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1346
|
-
const mgr =
|
|
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 =
|
|
1349
|
-
const row = mgr.list(env, opts).find((r) =>
|
|
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
|
|
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
|
|
1374
|
-
export function
|
|
1375
|
-
const mgr =
|
|
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
|
|
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
|
|
1382
|
-
const mgr =
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
1581
|
-
// which settings and trust. A later start or
|
|
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?,
|
|
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
|
|
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,
|
|
1976
|
+
export function resolveModelPreference(model, harness = "pi") {
|
|
1959
1977
|
const prefs = String(model || "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
1960
|
-
if (
|
|
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 (
|
|
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
|
|
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 (
|
|
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
|
|
2024
|
+
/** Verify the harness packages that ACTIVE capabilities require for `harness`.
|
|
2007
2025
|
*
|
|
2008
|
-
* Each comes from a declared
|
|
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
|
-
|
|
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 } :
|
|
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"
|
|
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 (!
|
|
2040
|
-
if (raw.marketplace !== undefined && !
|
|
2041
|
-
const status =
|
|
2042
|
-
const mgr =
|
|
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 ${
|
|
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 ${
|
|
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 ${
|
|
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 ${
|
|
2056
|
-
: `${cap.id} requires the ${
|
|
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 ${
|
|
2060
|
-
if (status.extensionsDisabled) { problems.push(`${cap.id} requires the ${
|
|
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 =
|
|
2068
|
-
if (!have) { problems.push(`${cap.id} requires the ${
|
|
2069
|
-
if (compareVersionTriples(have, raw.minVersion) < 0) { problems.push(`${cap.id} requires the ${
|
|
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 ${
|
|
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,
|
|
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("
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
2101
|
-
export function resolveLaunchExecutable({
|
|
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 =
|
|
2111
|
-
const name =
|
|
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:
|
|
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
|
|
2130
|
-
* model. A named configuration is a unit: an explicit
|
|
2131
|
-
* with it is refused. On an existing home, --
|
|
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
|
|
2134
|
-
* frozen model on the same
|
|
2135
|
-
/** Sentinel a caller passes as `model` to mean "the
|
|
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.
|
|
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,
|
|
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.
|
|
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
|
|
2158
|
-
if (!
|
|
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
|
|
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),
|
|
2168
|
-
if (!model) bad("E_MODEL_UNKNOWN", `model preference ${JSON.stringify(selection.model)} has no entry usable by
|
|
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,
|
|
2171
|
-
if (!model) bad("E_MODEL_UNKNOWN", `launch configuration ${config.name} names model ${JSON.stringify(config.model)}, which has no entry usable by
|
|
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.
|
|
2174
|
-
else { model = ""; modelSource = "native default (
|
|
2175
|
-
} else if (
|
|
2176
|
-
// A soul's model preference belongs to the soul's
|
|
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 (
|
|
2179
|
-
} else { model = resolveModelPreference(agent?.model || "",
|
|
2180
|
-
return { config,
|
|
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
|
-
*
|
|
2199
|
-
* configuration's args go after the
|
|
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 {
|
|
2258
|
+
const { harness, executable, model, yolo } = recipe;
|
|
2212
2259
|
const cfgArgs = (recipe.args || []).map(shq).join(" ");
|
|
2213
|
-
const hookArgs = recipe.hooks?.launch?.[
|
|
2260
|
+
const hookArgs = recipe.hooks?.launch?.[harness] || "";
|
|
2214
2261
|
const tail = `${cfgArgs ? ` ${cfgArgs}` : ""}${hookArgs ? ` ${hookArgs}` : ""}`;
|
|
2215
2262
|
let cmdline;
|
|
2216
|
-
if (
|
|
2263
|
+
if (harness === "claude") {
|
|
2217
2264
|
cmdline = `${shq(executable)}${yolo ? " --dangerously-skip-permissions" : ""}${model ? ` --model ${shq(model)}` : ""}${tail} -- "$(cat TASK.md)"`;
|
|
2218
|
-
} else if (
|
|
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,
|
|
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,
|
|
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(
|
|
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 (!
|
|
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
|
|
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(
|
|
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
|
|
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(
|
|
2276
|
-
const rows = applicableRequirements(
|
|
2277
|
-
return `launch configuration ${config?.name || "(recorded)"} passes arguments (${config.args.map((a) => JSON.stringify(a)).join(", ")}) that the ${
|
|
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,
|
|
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({
|
|
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 || "(
|
|
2304
|
-
// Capability contributions: recorded at spawn with provenance; a
|
|
2305
|
-
// switch needs the new
|
|
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
|
|
2310
|
-
// every capability that gave
|
|
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,
|
|
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: `${
|
|
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
|
-
//
|
|
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
|
|
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(
|
|
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("
|
|
2400
|
+
fail("harness-packages", "E_LAUNCH_PROBE_UNSUPPORTED", requirementsWithArgsMessage(harness, providers, config));
|
|
2353
2401
|
} else if (declared) {
|
|
2354
2402
|
try {
|
|
2355
|
-
|
|
2356
|
-
problems.push({ check: "
|
|
2357
|
-
} catch (e) { fail("
|
|
2358
|
-
} else problems.push({ check: "
|
|
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,
|
|
2362
|
-
executable: executable.path || executable.declared ||
|
|
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.
|
|
2370
|
-
return { recipe, command,
|
|
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
|
|
2464
|
-
// K6b: a preview's native probes (model catalogue,
|
|
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,
|
|
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
|
|
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
|
-
//
|
|
2811
|
-
//
|
|
2812
|
-
// to be named by path — and a required
|
|
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. `--
|
|
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({
|
|
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 || "(
|
|
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(
|
|
2830
|
-
const
|
|
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,
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
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}${
|
|
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
|
|
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,
|
|
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,
|
|
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
|
|
3544
|
+
// glob semantics belong to the harness, so it is auditable here rather
|
|
3497
3545
|
// than second-guessed at spawn.
|
|
3498
|
-
|
|
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
|
-
|
|
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
|
-
:
|
|
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,
|
|
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
|
|
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,
|
|
3613
|
-
if (launch) appendEvent(home, { kind: "launched", data: {
|
|
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
|
|
3636
|
-
const messaging =
|
|
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
|
|
4454
|
-
* never re-run). A
|
|
4455
|
-
* from every capability that contributed
|
|
4456
|
-
export function prepareLaunchHooks({ frozen,
|
|
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
|
|
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:
|
|
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 ${
|
|
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.
|
|
4510
|
-
if (unprepared.length) throw oatsError("E_LAUNCH_PREPARATION", `${unprepared.join(", ")} contributed ${frozen.
|
|
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,
|
|
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 } : {}), ...(
|
|
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,
|
|
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?.
|
|
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,
|
|
4656
|
-
if (o.model != null && String(o.model).trim() && resolveModelPreference(String(o.model), done.
|
|
4657
|
-
if (o.launchConfig !== undefined || o.
|
|
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
|
|
4667
|
-
if (!["pi", "claude", "codex"].includes(
|
|
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.
|
|
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 || {
|
|
4690
|
-
launchPlan = { recipe: plan.recipe, command: plan.command,
|
|
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),
|
|
4694
|
-
if (!resolved) throw oatsError("E_MODEL_UNKNOWN", `model preference ${JSON.stringify(o.model)} has no entry usable by
|
|
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,
|
|
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: {
|
|
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?.
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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;
|