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