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