@awebai/oats 0.31.0 → 0.32.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/bin/oats.mjs +141 -36
- package/docs/configuration.md +65 -8
- package/docs/desktop-cli-api.md +121 -16
- package/docs/desktop.md +16 -0
- package/docs/implementation.md +58 -0
- package/docs/oats-local.schema.json +2 -1
- package/docs/plans/0.30-close-out.md +7 -4
- package/docs/release-notes/v0.32.0.md +137 -0
- package/lib/automations.mjs +5 -1
- package/lib/core.mjs +75 -29
- package/lib/instance-inspect.mjs +2 -1
- package/lib/instance-resolution.mjs +3 -3
- package/lib/local-inputs.mjs +46 -0
- package/lib/materialize.mjs +3 -2
- package/lib/packages.mjs +26 -6
- package/lib/remote.mjs +792 -36
- package/lib/resolve.mjs +23 -7
- package/lib/servers.mjs +7 -0
- package/lib/workspace.mjs +163 -56
- package/package.json +1 -1
package/lib/core.mjs
CHANGED
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
} from "node:fs";
|
|
34
34
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
35
35
|
import { accessSync, constants as fsConstants } from "node:fs";
|
|
36
|
+
import { recordLocalInput } from "./local-inputs.mjs";
|
|
36
37
|
import { createHash, randomUUID } from "node:crypto";
|
|
37
38
|
import { fileURLToPath } from "node:url";
|
|
38
39
|
import { initializeNativeHistory, prepareNativeStart } from "../packages/record/lib/native-history.mjs";
|
|
@@ -183,7 +184,13 @@ export function slug(s) {
|
|
|
183
184
|
const r = String(s).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
184
185
|
return r || "agent";
|
|
185
186
|
}
|
|
186
|
-
|
|
187
|
+
/** `command -v <bin>`, once per (bin, PATH) in this process: the answer only changes with PATH. */
|
|
188
|
+
const whichMemo = new Map();
|
|
189
|
+
function which(bin) {
|
|
190
|
+
const key = `${bin}\0${process.env.PATH ?? ""}`;
|
|
191
|
+
if (!whichMemo.has(key)) whichMemo.set(key, shTry(`command -v ${shq(bin)}`));
|
|
192
|
+
return whichMemo.get(key);
|
|
193
|
+
}
|
|
187
194
|
|
|
188
195
|
// ---------- yaml-ish ----------
|
|
189
196
|
/** `__proto__` is never data in a plain-object mapping: assigning it REWRITES
|
|
@@ -525,7 +532,7 @@ export const LAYERS = ["knowledge", "messaging", "tasks"];
|
|
|
525
532
|
// deployment's oats-local.yaml (lead decision 2: a spawn-time HOST choice,
|
|
526
533
|
// never a soul field). Selected at spawn or session start/restart by name.
|
|
527
534
|
export const LAUNCH_HARNESSES = ["pi", "claude", "codex"];
|
|
528
|
-
export const LAUNCH_CONFIG_KEYS = new Set(["harness", "runtime", "executable", "args", "env", "model", "yolo"]);
|
|
535
|
+
export const LAUNCH_CONFIG_KEYS = new Set(["harness", "runtime", "executable", "args", "env", "model", "yolo", "default"]);
|
|
529
536
|
/** A launch configuration's harness: `harness`, or `runtime`, its pre-0.27 name (0.26.0
|
|
530
537
|
* deployments wrote it) — read either (lead call 6). */
|
|
531
538
|
export const launchConfigHarness = (entry) => (Object.hasOwn(entry, "harness") ? entry.harness : entry.runtime);
|
|
@@ -541,7 +548,7 @@ export function validateLaunchConfig(name, entry, where) {
|
|
|
541
548
|
if (typeof name !== "string" || !LAUNCH_CONFIG_NAME.test(name)) bad("has an invalid name (letters, digits, dot, underscore, dash; up to 64 characters)");
|
|
542
549
|
if (name === "none") bad("cannot be named none: that word selects no configuration");
|
|
543
550
|
if (!entry || typeof entry !== "object" || Array.isArray(entry)) bad("must be a map");
|
|
544
|
-
for (const key of Object.keys(entry)) if (!LAUNCH_CONFIG_KEYS.has(key)) bad(`has an unsupported key ${JSON.stringify(key)} (harness, executable, args, env, model, yolo)`);
|
|
551
|
+
for (const key of Object.keys(entry)) if (!LAUNCH_CONFIG_KEYS.has(key)) bad(`has an unsupported key ${JSON.stringify(key)} (harness, executable, args, env, model, yolo, default)`);
|
|
545
552
|
if (Object.hasOwn(entry, "harness") && Object.hasOwn(entry, "runtime") && entry.harness !== entry.runtime) bad(`names harness ${JSON.stringify(entry.harness)} and runtime ${JSON.stringify(entry.runtime)}: \`runtime\` is the pre-0.27 name of \`harness\` — keep one`);
|
|
546
553
|
if (!LAUNCH_HARNESSES.includes(launchConfigHarness(entry))) bad(`needs harness: one of ${LAUNCH_HARNESSES.join(", ")}`);
|
|
547
554
|
const text = (v, what) => { if (typeof v !== "string" || !v.trim() || v.includes("\0")) bad(`${what} must be non-empty text`); };
|
|
@@ -562,12 +569,25 @@ export function validateLaunchConfig(name, entry, where) {
|
|
|
562
569
|
}
|
|
563
570
|
if (entry.model !== undefined) text(entry.model, "model");
|
|
564
571
|
if (entry.yolo !== undefined && typeof entry.yolo !== "boolean") bad("yolo must be true or false");
|
|
572
|
+
if (entry.default !== undefined && typeof entry.default !== "boolean") bad("default must be true or false");
|
|
565
573
|
return entry;
|
|
566
574
|
}
|
|
575
|
+
/** At most one `default: true` per harness (0.32, feature launch-config-default): the host's baseline for
|
|
576
|
+
* that harness has one source. `map` is name → entry; entries are validated first. */
|
|
577
|
+
export function validateLaunchConfigDefaults(map, where) {
|
|
578
|
+
const byHarness = new Map();
|
|
579
|
+
for (const [name, entry] of Object.entries(map || {})) {
|
|
580
|
+
if (entry?.default !== true) continue;
|
|
581
|
+
const harness = launchConfigHarness(entry);
|
|
582
|
+
if (byHarness.has(harness)) throw Object.assign(oatsError("E_LAUNCH_CONFIG_INVALID", `launch configurations ${JSON.stringify(byHarness.get(harness))} and ${JSON.stringify(name)}${where ? ` in ${where}` : ""} are both default: true for ${harness}; keep one default per harness`), { details: { harness, configurations: [byHarness.get(harness), name] } });
|
|
583
|
+
byHarness.set(harness, name);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
567
586
|
function validateLaunchConfigs(map, file) {
|
|
568
587
|
if (map === undefined) return;
|
|
569
588
|
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`);
|
|
570
589
|
for (const [name, entry] of Object.entries(map)) validateLaunchConfig(name, entry, file);
|
|
590
|
+
validateLaunchConfigDefaults(map, file);
|
|
571
591
|
}
|
|
572
592
|
/** The launch configurations effective at `dir`: the `launch-configs:` of the
|
|
573
593
|
* oats-local.yaml found walking up from it (the deployment), validated. None
|
|
@@ -585,7 +605,7 @@ export function launchConfigsAt(dir) {
|
|
|
585
605
|
const source = dirname(found.path);
|
|
586
606
|
for (const [name, entry] of Object.entries(map || {})) {
|
|
587
607
|
if (!Object.hasOwn(entry, "harness")) noteRuntimeName(`launch-configs.${name}.runtime in ${found.path}`);
|
|
588
|
-
out[name] = { name, harness: launchConfigHarness(entry), ...(entry.executable !== undefined ? { executable: entry.executable } : {}), args: [...(entry.args || [])], env: { ...(entry.env || {}) }, ...(entry.model !== undefined ? { model: entry.model } : {}), ...(entry.yolo !== undefined ? { yolo: entry.yolo } : {}), source, shadows: [] };
|
|
608
|
+
out[name] = { name, harness: launchConfigHarness(entry), ...(entry.executable !== undefined ? { executable: entry.executable } : {}), args: [...(entry.args || [])], env: { ...(entry.env || {}) }, ...(entry.model !== undefined ? { model: entry.model } : {}), ...(entry.yolo !== undefined ? { yolo: entry.yolo } : {}), ...(entry.default === true ? { default: true } : {}), source, shadows: [] };
|
|
589
609
|
}
|
|
590
610
|
return out;
|
|
591
611
|
}
|
|
@@ -858,9 +878,14 @@ export function officialPackageCatalog() {
|
|
|
858
878
|
function readCatalogFile() {
|
|
859
879
|
const file = officialCatalogFile();
|
|
860
880
|
const empty = { packages: Object.create(null), capabilities: Object.create(null), file };
|
|
861
|
-
|
|
881
|
+
// An OATS_PACKAGE_CATALOG override is local configuration (observation.localRevision); the bundled
|
|
882
|
+
// catalog belongs to the kernel install.
|
|
883
|
+
const override = !!process.env.OATS_PACKAGE_CATALOG;
|
|
884
|
+
if (!existsSync(file)) { if (override) recordLocalInput(file, null); return empty; }
|
|
862
885
|
let doc;
|
|
863
|
-
|
|
886
|
+
const text = readFileSync(file, "utf8");
|
|
887
|
+
if (override) recordLocalInput(file, text);
|
|
888
|
+
try { doc = JSON.parse(text); }
|
|
864
889
|
catch (e) { throw oatsError("invalid-source", `broken package catalog ${file}: ${e.message}`); }
|
|
865
890
|
if (!doc || typeof doc !== "object" || Array.isArray(doc)) throw oatsError("invalid-source", `broken package catalog ${file}: root must be a JSON object`);
|
|
866
891
|
const out = { packages: Object.create(null), capabilities: Object.create(null), file };
|
|
@@ -1247,7 +1272,7 @@ export const HARNESS_PACKAGE_MANAGERS = {
|
|
|
1247
1272
|
* different marketplaces into one identity. */
|
|
1248
1273
|
identity: (spec) => String(spec || "").trim(),
|
|
1249
1274
|
safeSpec: (spec) => typeof spec === "string" && /^[a-z0-9][\w.-]*@[a-z0-9][\w.-]*$/i.test(spec),
|
|
1250
|
-
/** The executable is
|
|
1275
|
+
/** The executable is SELECTED by the launch (a configuration may name a wrapper
|
|
1251
1276
|
* such as `claude-personal`). Probing and installing through the literal
|
|
1252
1277
|
* `claude` would inspect a DIFFERENT account's plugins than the session
|
|
1253
1278
|
* actually launches with — passing preflight while the real harness lacks
|
|
@@ -1848,20 +1873,21 @@ export function tmuxWindows(session = DEFAULT_TMUX_SESSION) {
|
|
|
1848
1873
|
* Spawn an instance of `agent` (as returned by findAgent/listAgents).
|
|
1849
1874
|
* o: { instance?, purpose?, name?, repo?, work?, harness?, model?, task?, taskFile?, branch?, launch?, tmuxSession? }
|
|
1850
1875
|
*/
|
|
1851
|
-
/**
|
|
1852
|
-
*
|
|
1853
|
-
*
|
|
1854
|
-
|
|
1855
|
-
export function resolveClaudeBinary(contextDir) {
|
|
1876
|
+
/** `oats-claude-config` (a one-line file naming the claude binary, found walking up from the context) is no
|
|
1877
|
+
* longer read (0.32): a host names its claude executable in a default launch configuration. A file still in
|
|
1878
|
+
* reach of a new claude launch refuses it, naming the file and the configuration to declare instead. */
|
|
1879
|
+
export function legacyClaudeConfigRefusal(contextDir) {
|
|
1856
1880
|
let d = resolve(contextDir);
|
|
1857
1881
|
while (true) {
|
|
1858
1882
|
const f = join(d, "oats-claude-config");
|
|
1859
1883
|
if (existsSync(f)) {
|
|
1860
|
-
|
|
1861
|
-
|
|
1884
|
+
let name = null;
|
|
1885
|
+
try { name = readFileSync(f, "utf8").split("\n").map((l) => l.trim()).find((l) => l && !l.startsWith("#")) || null; } catch {}
|
|
1886
|
+
const exe = name ? `executable: ${name}, ` : "";
|
|
1887
|
+
return Object.assign(oatsError("E_CLAUDE_CONFIG_REMOVED", `${f} is no longer read (OATS 0.32): declare this machine's claude default in oats-local.yaml instead — launch-configs: { <name>: { harness: claude, ${exe}default: true } } (oats launch-config set <name> --file <json>) — then delete ${f}`), { details: { file: f, executable: name, fix: `declare a claude default launch configuration (${exe}default: true), then delete ${f}` } });
|
|
1862
1888
|
}
|
|
1863
1889
|
const parent = dirname(d);
|
|
1864
|
-
if (parent === d) return
|
|
1890
|
+
if (parent === d) return null;
|
|
1865
1891
|
d = parent;
|
|
1866
1892
|
}
|
|
1867
1893
|
}
|
|
@@ -1961,11 +1987,10 @@ function verifyHarnessPackages(harness, resolved, contextDir, { bin, env } = {})
|
|
|
1961
1987
|
const found = [];
|
|
1962
1988
|
const problems = [];
|
|
1963
1989
|
// The session launches with the SELECTED executable (a configuration's,
|
|
1964
|
-
//
|
|
1965
|
-
// `claude-personal`), so probing another binary would inspect a different
|
|
1990
|
+
// the host's harness default included), so probing another binary would inspect a different
|
|
1966
1991
|
// account's packages than the instance will actually use. The probe is the
|
|
1967
1992
|
// manager's controlled list subcommand; no launch argument is added to it.
|
|
1968
|
-
const probeOpts = { context: contextDir, ...(bin ? { bin } :
|
|
1993
|
+
const probeOpts = { context: contextDir, ...(bin ? { bin } : {}) };
|
|
1969
1994
|
for (const cap of resolved.capabilities || []) {
|
|
1970
1995
|
for (const raw of cap.manifest?.requires || []) {
|
|
1971
1996
|
if (!raw || typeof raw !== "object") continue;
|
|
@@ -2059,8 +2084,10 @@ const LAUNCH_PROMPT = { kind: "task-file", file: "TASK.md" };
|
|
|
2059
2084
|
|
|
2060
2085
|
/** The executable a launch uses: a configuration's declared one (a bare name
|
|
2061
2086
|
* on PATH; a path against the deployment directory when relative) or the
|
|
2062
|
-
* harness's
|
|
2087
|
+
* harness's name on PATH. Never executed. A new claude launch with a legacy `oats-claude-config` in reach
|
|
2088
|
+
* of its context is refused (E_CLAUDE_CONFIG_REMOVED, 0.32), whatever it declares. */
|
|
2063
2089
|
export function resolveLaunchExecutable({ harness, declared, declaringDir, contextDir }) {
|
|
2090
|
+
if (harness === "claude" && contextDir) { const refused = legacyClaudeConfigRefusal(contextDir); if (refused) throw refused; }
|
|
2064
2091
|
if (declared) {
|
|
2065
2092
|
if (declared.includes("/")) {
|
|
2066
2093
|
const path = isAbsolute(declared) ? declared : resolve(declaringDir || contextDir, declared);
|
|
@@ -2069,10 +2096,8 @@ export function resolveLaunchExecutable({ harness, declared, declaringDir, conte
|
|
|
2069
2096
|
const found = which(declared);
|
|
2070
2097
|
return { path: found || null, declared, resolvedFrom: "PATH", missing: found ? undefined : `${declared} binary not found on PATH` };
|
|
2071
2098
|
}
|
|
2072
|
-
const
|
|
2073
|
-
|
|
2074
|
-
const found = which(name);
|
|
2075
|
-
return { path: found || null, declared: null, resolvedFrom: harness === "claude" && claudeBin !== "claude" ? "oats-claude-config" : "PATH", missing: found ? undefined : `${name} binary not found on PATH${claudeBin && claudeBin !== "claude" ? " (named by oats-claude-config)" : ""}` };
|
|
2099
|
+
const found = which(harness);
|
|
2100
|
+
return { path: found || null, declared: null, resolvedFrom: "PATH", missing: found ? undefined : `${harness} binary not found on PATH` };
|
|
2076
2101
|
}
|
|
2077
2102
|
/** null when `path` is a regular executable file; otherwise why not. */
|
|
2078
2103
|
export function checkLaunchExecutable(path) {
|
|
@@ -2107,7 +2132,7 @@ export function resolveLaunchSelection({ launchConfigs = {}, agent, frozen, sele
|
|
|
2107
2132
|
// applies the current definition.
|
|
2108
2133
|
// Named or not: the recorded executable (a saved wrapper, say), args and
|
|
2109
2134
|
// env are what runs; later edits of the scope never change it.
|
|
2110
|
-
config = { name: frozen.launchConfig || null, harness: frozen.harness, ...(frozen.executableDeclared ? { executable: frozen.executableDeclared } : {}), executablePath: frozen.executable, args: [...(frozen.args || [])], env: { ...(frozen.env || {}) }, ...(frozen.model ? { model: frozen.model } : {}), ...(frozen.yolo !== undefined ? { yolo: frozen.yolo } : {}), source: frozen.launchConfigSource || null, frozen: true };
|
|
2135
|
+
config = { name: frozen.launchConfig || null, harness: frozen.harness, ...(frozen.executableDeclared ? { executable: frozen.executableDeclared } : {}), executablePath: frozen.executable, args: [...(frozen.args || [])], env: { ...(frozen.env || {}) }, ...(frozen.model ? { model: frozen.model } : {}), ...(frozen.yolo !== undefined ? { yolo: frozen.yolo } : {}), source: frozen.launchConfigSource || null, ...(frozen.launchConfigDefault === true ? { harnessDefault: true } : {}), frozen: true };
|
|
2111
2136
|
wanted = "none";
|
|
2112
2137
|
}
|
|
2113
2138
|
if (wanted === undefined) wanted = frozen ? "none" : (agent?.["launch-config"] || "none");
|
|
@@ -2120,6 +2145,9 @@ export function resolveLaunchSelection({ launchConfigs = {}, agent, frozen, sele
|
|
|
2120
2145
|
// host default would: below the flags and a selected configuration.
|
|
2121
2146
|
const harness = config?.harness || selection.harness || (frozen ? frozen.harness : preference?.harness || agent?.harness || "pi");
|
|
2122
2147
|
if (!LAUNCH_HARNESSES.includes(harness)) bad("E_UNSUPPORTED_HARNESS", `unknown harness "${harness}" (pi|claude|codex)`);
|
|
2148
|
+
// 0.32 (feature launch-config-default): a launch that selected no configuration runs this host's default
|
|
2149
|
+
// for its harness, if one is declared. An explicit `--launch-config none` asks for the bare harness.
|
|
2150
|
+
const harnessDefault = !config && !(selection.launchConfig === "none" && !preference) ? harnessDefaultConfig(launchConfigs, harness) : null;
|
|
2123
2151
|
let model, modelSource;
|
|
2124
2152
|
// K6: an EXPLICIT "use the harness's native default" is distinct from an
|
|
2125
2153
|
// omitted model (which inherits the configuration's or soul's preference).
|
|
@@ -2145,8 +2173,19 @@ export function resolveLaunchSelection({ launchConfigs = {}, agent, frozen, sele
|
|
|
2145
2173
|
// is no proof it fits another one. Nothing is passed across.
|
|
2146
2174
|
model = ""; modelSource = "native default (harness differs from the soul's)";
|
|
2147
2175
|
} else { model = resolveModelPreference(agent?.model || "", harness); modelSource = model ? "soul default" : "native default"; }
|
|
2176
|
+
// The harness default's model is the last fallback: below every layer that names one, above the harness's own.
|
|
2177
|
+
if (harnessDefault?.model && !model && !nativeDefault) {
|
|
2178
|
+
model = resolveModelPreference(harnessDefault.model, harness); modelSource = `launch-config ${harnessDefault.name}`;
|
|
2179
|
+
if (!model) bad("E_MODEL_UNKNOWN", `launch configuration ${harnessDefault.name} (the ${harness} default) names model ${JSON.stringify(harnessDefault.model)}, which has no entry usable by harness ${harness}`);
|
|
2180
|
+
}
|
|
2181
|
+
if (harnessDefault) config = harnessDefault;
|
|
2148
2182
|
return { config, harness, model, modelSource, configuredYolo: config?.yolo };
|
|
2149
2183
|
}
|
|
2184
|
+
/** This host's default launch configuration for `harness` (`default: true`), marked `harnessDefault`, or null. */
|
|
2185
|
+
export function harnessDefaultConfig(launchConfigs, harness) {
|
|
2186
|
+
const found = Object.values(launchConfigs || {}).find((c) => c?.default === true && c.harness === harness);
|
|
2187
|
+
return found ? { ...found, harnessDefault: true } : null;
|
|
2188
|
+
}
|
|
2150
2189
|
/** A home's launch layers now (feature launch-preference; --reselect-launch and inspect's launchCurrent): its
|
|
2151
2190
|
* RECORDED soul's launch (the soul copy it runs) and its deployment's oats-local.yaml souls.launch. */
|
|
2152
2191
|
export function homeLaunchLayers(realHome, meta) {
|
|
@@ -2186,7 +2225,11 @@ export function launchReportFor({ layers, launchConfigs = {}, contextDir }) {
|
|
|
2186
2225
|
if (!Object.hasOwn(launchConfigs, name)) { const e = launchConfigUnknown({ name, from: choice.from, at: choice.at }); return report("pi", null, null, { code: e.code, message: e.message, fix: `declare launch configuration ${name} (oats launch-config set), or change oats-local.yaml souls.launch` }); }
|
|
2187
2226
|
config = launchConfigs[name]; harness = config.harness; model = typeof config.model === "string" && config.model ? config.model : null;
|
|
2188
2227
|
} else if (choice.preference) { harness = choice.preference.harness; model = choice.preference.model ?? null; }
|
|
2189
|
-
|
|
2228
|
+
// As a spawn: a layer naming "none" asks for the bare harness; otherwise no configuration is the harness default.
|
|
2229
|
+
if (!config && !(choice.selection.launchConfig === "none" && !choice.preference)) { config = harnessDefaultConfig(launchConfigs, harness); if (config && !model) model = typeof config.model === "string" && config.model ? config.model : null; }
|
|
2230
|
+
let exe;
|
|
2231
|
+
try { exe = resolveLaunchExecutable({ harness, declared: config?.executable, declaringDir: config?.source, contextDir }); }
|
|
2232
|
+
catch (e) { if (e?.code !== "E_CLAUDE_CONFIG_REMOVED") throw e; return report(harness, model, config?.name, { code: e.code, message: e.message, fix: `declare a claude default launch configuration (oats launch-config set), then delete ${e.details.file}` }); }
|
|
2190
2233
|
if (!exe.path && !config?.executable) { const e = harnessUnavailable({ harness, from: choice.from, at: choice.at, why: exe.missing }); return report(harness, model, config?.name, { code: e.code, message: e.message, fix: e.details.fix }); }
|
|
2191
2234
|
if (!exe.path) return report(harness, model, config.name, { code: "E_LAUNCH_EXECUTABLE", message: `launch configuration ${config.name}: ${exe.missing}`, fix: `fix the executable of launch configuration ${config.name} (oats launch-config set)` });
|
|
2192
2235
|
return report(harness, model, config?.name);
|
|
@@ -2364,7 +2407,10 @@ export function planLaunch({ home, instance, meta, contextDir, agentLike, select
|
|
|
2364
2407
|
if (selection.launchConfig !== undefined || selection.harness !== undefined) launchChoice = { from: "flag", at: null, declared: meta?.launchDeclared ?? null };
|
|
2365
2408
|
}
|
|
2366
2409
|
const { config, harness, model, modelSource } = chosen;
|
|
2367
|
-
|
|
2410
|
+
// A recorded yolo that came from a harness default belongs to that default: it never carries into a launch
|
|
2411
|
+
// the default no longer decides (a bare --launch-config none, another harness).
|
|
2412
|
+
const frozenYolo = frozen && !(frozen.launchConfigDefault === true && !config?.frozen) ? frozen.yolo : undefined;
|
|
2413
|
+
const yolo = resolveYolo(selection.yolo ?? chosen.configuredYolo ?? (frozen ? frozenYolo : agentLike?.yolo ?? resolvedCfg?.yolo));
|
|
2368
2414
|
const executable = config?.frozen
|
|
2369
2415
|
? { path: config.executablePath, declared: frozen.executableDeclared ?? null, resolvedFrom: frozen.executableResolvedFrom || "recorded", missing: existsSync(config.executablePath) ? undefined : `${config.executablePath} (recorded) does not exist` }
|
|
2370
2416
|
: resolveLaunchExecutable({ harness, declared: config?.executable, declaringDir: config?.source, contextDir });
|
|
@@ -2432,7 +2478,7 @@ export function planLaunch({ home, instance, meta, contextDir, agentLike, select
|
|
|
2432
2478
|
} else problems.push({ check: "harness-packages", ok: true, detail: "no harness package requirement declared for this harness; nothing probed" });
|
|
2433
2479
|
}
|
|
2434
2480
|
const recipe = {
|
|
2435
|
-
version: LAUNCH_RECIPE_VERSION, harness, launchConfig: config?.name || null, launchConfigSource: config?.source || null,
|
|
2481
|
+
version: LAUNCH_RECIPE_VERSION, harness, launchConfig: config?.name || null, launchConfigSource: config?.source || null, ...(config?.harnessDefault ? { launchConfigDefault: true } : {}),
|
|
2436
2482
|
executable: executable.path || executable.declared || harness, executableDeclared: executable.declared ?? null, executableResolvedFrom: executable.resolvedFrom,
|
|
2437
2483
|
args: [...(config?.args || [])], env: { ...configEnv }, model: model || null, ...(yolo !== undefined ? { yolo } : {}),
|
|
2438
2484
|
hooks: frozen ? { launch: hooks.launch, env: hooks.env, contributions: hooks.contributions } : hooks, prompt: LAUNCH_PROMPT, kernelBin: kernelBin(),
|
|
@@ -2984,7 +3030,7 @@ function* spawnBody(root, agent, o = {}) {
|
|
|
2984
3030
|
subject: o.subject ?? { soul: agent.name, agentsRoot: root, context: null },
|
|
2985
3031
|
decision, preflight,
|
|
2986
3032
|
backendStatus: launch ? { name: backend, installed: !!which(backend), started: false } : null,
|
|
2987
|
-
harness, model: model || null, modelSource: launchSelection.modelSource ?? null, launchConfig: launchConfig?.name ?? null, yolo, backend,
|
|
3033
|
+
harness, model: model || null, modelSource: launchSelection.modelSource ?? null, launchConfig: launchConfig?.name ?? null, launchConfigDefault: launchConfig?.harnessDefault === true, yolo, backend,
|
|
2988
3034
|
launch: launchReport({ declared: launchChoice.declared, harness, model, launchConfig: launchConfig?.name, from: launchChoice.from, at: launchChoice.at }),
|
|
2989
3035
|
branch: plannedBranch, base: plannedBase, worktree: work === "worktree" ? join(home, "work") : null,
|
|
2990
3036
|
relation: relation || null, parentInstance: parentInstance && parentInstance !== instance ? parentInstance : null,
|
|
@@ -3523,7 +3569,7 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
|
|
|
3523
3569
|
const shimTarget = writeKernelShim(home);
|
|
3524
3570
|
const recipe = {
|
|
3525
3571
|
version: LAUNCH_RECIPE_VERSION, harness,
|
|
3526
|
-
launchConfig: launchConfig?.name || null, launchConfigSource: launchConfig?.source || null,
|
|
3572
|
+
launchConfig: launchConfig?.name || null, launchConfigSource: launchConfig?.source || null, ...(launchConfig?.harnessDefault ? { launchConfigDefault: true } : {}),
|
|
3527
3573
|
executable: bin, executableDeclared: executable.declared, executableResolvedFrom: executable.resolvedFrom,
|
|
3528
3574
|
args: [...(launchConfig?.args || [])], env: { ...(launchConfig?.env || {}) },
|
|
3529
3575
|
model: model || null, ...(yolo !== undefined ? { yolo } : {}),
|
package/lib/instance-inspect.mjs
CHANGED
|
@@ -265,7 +265,8 @@ export function inspectDocument(t, { kernel }) {
|
|
|
265
265
|
// What a new tmux spawn here would open in: {tmuxSession} (0.31).
|
|
266
266
|
session: t.session ?? null,
|
|
267
267
|
knowledge: kcap ? { provider: kcap.id, version: kcap.version, operations: kcap.operations.map((o) => ({ name: o.name, kind: o.kind, available: o.available, reason: o.reason })) } : { provider: null, version: null, operations: [] },
|
|
268
|
-
instance: m ? { home: t.home, instance: m.instance, agent: m.agent, harness: m.harness || null, model: m.model ?? null, yolo: m.yolo ?? null,
|
|
268
|
+
instance: m ? { home: t.home, instance: m.instance, agent: m.agent, harness: m.harness || null, model: m.model ?? null, yolo: m.yolo ?? null,
|
|
269
|
+
launchConfig: m.launch?.launchConfig ?? null, launchConfigDefault: m.launch?.launchConfigDefault === true, launched: !!m.launched, createdAt: m.createdAt || null,
|
|
269
270
|
resolution: m.workspace?.resolution ?? null, soulDir: t.soul.soulDir, instructions: { ...readTextCapped(join(t.home, "AGENTS.md")), sources: m.instructions || [] } } : null,
|
|
270
271
|
...(m ? { identity: servedIdentityOf(m) } : {}),
|
|
271
272
|
problems: [...t.soul.problems, ...(t.soul.copyError ? [t.soul.copyError] : []), ...(t.discoveryError ? [t.discoveryError] : []),
|
|
@@ -30,7 +30,7 @@ import { mkdirSync, mkdtempSync, renameSync, rmSync, writeFileSync, symlinkSync,
|
|
|
30
30
|
import { tmpdir } from "node:os";
|
|
31
31
|
import { spawnSync } from "node:child_process";
|
|
32
32
|
import { randomBytes } from "node:crypto";
|
|
33
|
-
import { readLock, LOCK_FILE, SOUL_ALIAS_SYMLINK } from "./packages.mjs";
|
|
33
|
+
import { readLock, readLockIfPresent, LOCK_FILE, SOUL_ALIAS_SYMLINK } from "./packages.mjs";
|
|
34
34
|
import * as defaultRemote from "./remote.mjs";
|
|
35
35
|
import { parseRepoRef } from "./remote.mjs";
|
|
36
36
|
|
|
@@ -438,7 +438,7 @@ export async function prepareInstance(contextDir, soulName, { spawn = {}, remote
|
|
|
438
438
|
const found = localOverride ? { path: null, local: localOverride } : loadLocal(contextDir);
|
|
439
439
|
const local = found.local;
|
|
440
440
|
const deployment = found.path ? dirname(found.path) : resolvePath(contextDir);
|
|
441
|
-
const lock =
|
|
441
|
+
const lock = readLockIfPresent(deployment);
|
|
442
442
|
const discovery = discoveryOverride ?? await discoverOrStandalone(local, { lock, remoteOptions, remote });
|
|
443
443
|
const soulEntry = findSoulEntry(discovery, soulName);
|
|
444
444
|
const disabled = disabledEntry(local, soulEntry);
|
|
@@ -458,7 +458,7 @@ const isAccessFailure = (e) => e?.code === "E_REMOTE_UNREADABLE" && ACCESS_REASO
|
|
|
458
458
|
|
|
459
459
|
/** The deployment's lock, or null when it has none (an unreadable lock is E_LOCK_SCHEMA). */
|
|
460
460
|
export function deploymentLock(deployment) {
|
|
461
|
-
return
|
|
461
|
+
return readLockIfPresent(deployment);
|
|
462
462
|
}
|
|
463
463
|
|
|
464
464
|
/** Decision 10: a standalone view is a MEMBER whose workspace cannot be read — the
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/** lib/local-inputs.mjs — the local configuration a command read, for `observation.localRevision`
|
|
2
|
+
* (spec Addendum 4; docs/desktop-cli-api.md "Observation reuse").
|
|
3
|
+
*
|
|
4
|
+
* Every reader of local configuration on the read verbs' paths reports what it parsed:
|
|
5
|
+
* `recordLocalInput(absPath, bytes)` for a file it read, `recordLocalInput(absPath, null)` for one it
|
|
6
|
+
* looked for and found absent (a file appearing changes the answer as much as one changing). The inputs:
|
|
7
|
+
* oats-local.yaml and every walk-up candidate loadLocal found absent (lib/workspace.mjs), oats-lock.json
|
|
8
|
+
* (lib/packages.mjs readLock / readLockIfPresent), an OATS_PACKAGE_CATALOG override (lib/core.mjs; the
|
|
9
|
+
* bundled catalog is the kernel's, not configuration), the automations snapshot (lib/automations.mjs).
|
|
10
|
+
* NOT inputs: instance homes and runtime state (the answer reports them), the parsed and observation caches.
|
|
11
|
+
*
|
|
12
|
+
* Recording is a no-op until the CLI activates it for this process (a command with --max-age), so
|
|
13
|
+
* library callers and tests are unaffected unless they opt in. The first bytes recorded for a path win.
|
|
14
|
+
*/
|
|
15
|
+
import { createHash } from "node:crypto";
|
|
16
|
+
import { realpathSync } from "node:fs";
|
|
17
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
18
|
+
|
|
19
|
+
let inputs = null; // canonical path → sha256(bytes) | "absent", while active
|
|
20
|
+
|
|
21
|
+
/** Start recording for this process (idempotent; clears nothing already recorded). */
|
|
22
|
+
export function activateLocalInputs() { inputs ??= new Map(); }
|
|
23
|
+
|
|
24
|
+
/** A file's canonical path: its realpath when it exists, else its directory's realpath plus its name
|
|
25
|
+
* (else the resolved path), so a symlinked deployment gives one revision. */
|
|
26
|
+
function canonical(absPath) {
|
|
27
|
+
const path = resolve(absPath);
|
|
28
|
+
try { return realpathSync(path); } catch { /* absent */ }
|
|
29
|
+
try { return join(realpathSync(dirname(path)), basename(path)); } catch { return path; }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Record one input: the bytes parsed (Buffer | string), or null for a file looked for and absent. */
|
|
33
|
+
export function recordLocalInput(absPath, bytes) {
|
|
34
|
+
if (!inputs) return;
|
|
35
|
+
const key = canonical(absPath);
|
|
36
|
+
if (inputs.has(key)) return;
|
|
37
|
+
inputs.set(key, bytes === null || bytes === undefined ? "absent" : createHash("sha256").update(bytes).digest("hex"));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 24 lowercase hex: sha256 over the sorted `path NUL digest|absent` lines of every recorded input (the
|
|
41
|
+
* empty set included). Never a path or content. null when recording is not active. */
|
|
42
|
+
export function localRevision() {
|
|
43
|
+
if (!inputs) return null;
|
|
44
|
+
const lines = [...inputs].map(([path, digest]) => `${path}\0${digest}`).sort();
|
|
45
|
+
return createHash("sha256").update(lines.join("\n")).digest("hex").slice(0, 24);
|
|
46
|
+
}
|
package/lib/materialize.mjs
CHANGED
|
@@ -59,6 +59,7 @@ import { oatsError } from "./errors.mjs";
|
|
|
59
59
|
import * as defaultRemote from "./remote.mjs";
|
|
60
60
|
import { renderInstructionText } from "./instruction-composition.mjs";
|
|
61
61
|
import { DEFAULT_PACKAGE_PATH, validateLock } from "./packages.mjs";
|
|
62
|
+
import { memberRowByKey } from "./workspace.mjs";
|
|
62
63
|
|
|
63
64
|
export const MODULES_DIR = join(".oats", "modules");
|
|
64
65
|
export const SKILLS_DIR = join(".agents", "skills");
|
|
@@ -572,7 +573,7 @@ export function driftOf(instanceJson, discovery, { lock } = {}) {
|
|
|
572
573
|
}
|
|
573
574
|
const repoKey = typeof from.repoKey === "string" ? from.repoKey : null;
|
|
574
575
|
const recorded = { repoKey, commit };
|
|
575
|
-
const member = repoKey ? members
|
|
576
|
+
const member = repoKey ? memberRowByKey(members, repoKey) : null;
|
|
576
577
|
if (!member || !member.confirmed || typeof member.commit !== "string") {
|
|
577
578
|
rows.push({ module: name, from: clone(from), recorded, current: null, status: "missing", reason: member ? (member.reason || "unconfirmed") : "unconfirmed" });
|
|
578
579
|
continue;
|
|
@@ -616,7 +617,7 @@ export function soulDriftOf(instanceJson, discovery) {
|
|
|
616
617
|
return { ...base, current: { commit: now.commit, version: now.version }, status: now.commit === commit ? "current" : "moved" };
|
|
617
618
|
}
|
|
618
619
|
const members = Array.isArray(discovery?.members) ? discovery.members : [];
|
|
619
|
-
const member = members
|
|
620
|
+
const member = memberRowByKey(members, soul.repoKey);
|
|
620
621
|
const standaloneOwn = discovery?.standalone === true && member && member.key === discovery.key && typeof member.commit === "string";
|
|
621
622
|
const base = { name, repoKey: soul.repoKey, commit };
|
|
622
623
|
if (!member || (!member.confirmed && !standaloneOwn) || typeof member.commit !== "string") {
|
package/lib/packages.mjs
CHANGED
|
@@ -44,6 +44,7 @@
|
|
|
44
44
|
import { existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync, lstatSync } from "node:fs";
|
|
45
45
|
import { tmpdir } from "node:os";
|
|
46
46
|
import { basename, dirname, isAbsolute, join, posix, relative, resolve, sep } from "node:path";
|
|
47
|
+
import { recordLocalInput } from "./local-inputs.mjs";
|
|
47
48
|
import { oatsError as baseOatsError } from "./errors.mjs";
|
|
48
49
|
import * as defaultRemote from "./remote.mjs";
|
|
49
50
|
import { manifestContractProblems } from "./capability-contract.mjs";
|
|
@@ -165,14 +166,23 @@ export function validateLock(lock, { file } = {}) {
|
|
|
165
166
|
* else is validated (E_LOCK_SCHEMA). Returns a fresh object every call. */
|
|
166
167
|
export function readLock(dir) {
|
|
167
168
|
const file = join(resolve(dir), LOCK_FILE);
|
|
168
|
-
if (!existsSync(file)) return emptyLock();
|
|
169
|
+
if (!existsSync(file)) { recordLocalInput(file, null); return emptyLock(); }
|
|
169
170
|
let parsed;
|
|
170
|
-
|
|
171
|
+
const text = readFileSync(file, "utf8");
|
|
172
|
+
recordLocalInput(file, text);
|
|
173
|
+
try { parsed = JSON.parse(text); } catch (e) {
|
|
171
174
|
throw oatsError("E_LOCK_SCHEMA", `oats-lock.json is not valid JSON (${file}): ${e.message}`, { file, path: "/" });
|
|
172
175
|
}
|
|
173
176
|
return validateLock(parsed, { file });
|
|
174
177
|
}
|
|
175
178
|
|
|
179
|
+
/** `<dir>/oats-lock.json` read as readLock does, or null when there is none (recorded as absent). */
|
|
180
|
+
export function readLockIfPresent(dir) {
|
|
181
|
+
const file = join(resolve(dir), LOCK_FILE);
|
|
182
|
+
if (!existsSync(file)) { recordLocalInput(file, null); return null; }
|
|
183
|
+
return readLock(dir);
|
|
184
|
+
}
|
|
185
|
+
|
|
176
186
|
/** Canonical lock text: sorted package ids, sorted capability names, 2-space indent, trailing newline. */
|
|
177
187
|
export function canonicalLock(lock) {
|
|
178
188
|
validateLock(lock);
|
|
@@ -309,8 +319,14 @@ async function readJson(remote, remoteRef, commit, path, what, details) {
|
|
|
309
319
|
|
|
310
320
|
/** Read `oats-package.json` and every capability manifest it lists.
|
|
311
321
|
* → { manifest, capabilities: [{ name, dir, manifest }] } (sorted by name, codepoint order).
|
|
312
|
-
* Two capability dirs declaring the same name → E_PACKAGE_MANIFEST { duplicate }.
|
|
322
|
+
* Two capability dirs declaring the same name → E_PACKAGE_MANIFEST { duplicate }.
|
|
323
|
+
* The answer is a pure function of the bytes at the commit: a remote with the parsed cache
|
|
324
|
+
* (lib/remote.mjs memoAtCommit) keeps it; a refusal is thrown and never kept. */
|
|
313
325
|
export async function readPackageManifests(remote, remoteRef, commit, path, details = {}) {
|
|
326
|
+
const read = () => readPackageManifestsAt(remote, remoteRef, commit, path, details);
|
|
327
|
+
return typeof remote.memoAtCommit === "function" ? remote.memoAtCommit(remoteRef, commit, `package-manifests\0${path}`, read) : read();
|
|
328
|
+
}
|
|
329
|
+
async function readPackageManifestsAt(remote, remoteRef, commit, path, details) {
|
|
314
330
|
const manifestPath = pjoin(path, PACKAGE_MANIFEST);
|
|
315
331
|
const manifest = await readJson(remote, remoteRef, commit, manifestPath, "package manifest", details);
|
|
316
332
|
if (!plainObject(manifest) || typeof manifest.package !== "string" || !Array.isArray(manifest.capabilities)) {
|
|
@@ -412,7 +428,6 @@ function assertDigest(what, value, details) {
|
|
|
412
428
|
return value;
|
|
413
429
|
}
|
|
414
430
|
|
|
415
|
-
/** Bind `remoteOptions` (cacheDir, exec, …) into every call of a contract-§1 remote. */
|
|
416
431
|
/** A remote whose reads at a commit are shared for one command (feature desktop-facts): the souls and
|
|
417
432
|
* capabilities listings resolve many souls over the same package manifests and skill listings, and a read at
|
|
418
433
|
* an immutable commit gives the same answer every time. A failed read is not kept (it is retried). */
|
|
@@ -430,13 +445,18 @@ export function memoizedRemote(remote) {
|
|
|
430
445
|
return memo;
|
|
431
446
|
}
|
|
432
447
|
|
|
448
|
+
/** Bind `remoteOptions` (cacheDir, exec, the command's read `session`, …) into every call of a contract-§1
|
|
449
|
+
* remote. Object references are kept (a spread, never a copy of the values), so one session is shared by
|
|
450
|
+
* every call; `memoAtCommit` (the parsed cache, lib/remote.mjs) and the member prefetch's primitives
|
|
451
|
+
* (`lastObservedCommit`, `peekAtCommit`, `prefetchObservation`) are optional and bound when present. */
|
|
433
452
|
export function bindRemote(remote, remoteOptions) {
|
|
434
453
|
if (!remoteOptions || Object.keys(remoteOptions).length === 0) return remote;
|
|
435
454
|
const bound = { ...remote };
|
|
436
|
-
|
|
455
|
+
const arities = { observeRemote: 1, readRemoteFile: 3, listRemoteTree: 3, fetchRemoteTree: 4, memoAtCommit: 4, lastObservedCommit: 1, peekAtCommit: 3, prefetchObservation: 1, abandonPrefetches: 0 };
|
|
456
|
+
for (const name of Object.keys(arities)) {
|
|
437
457
|
if (typeof remote[name] !== "function") continue;
|
|
438
458
|
bound[name] = (...args) => {
|
|
439
|
-
const arity =
|
|
459
|
+
const arity = arities[name];
|
|
440
460
|
const opts = { ...(args[arity] || {}), ...remoteOptions };
|
|
441
461
|
return remote[name](...args.slice(0, arity), opts);
|
|
442
462
|
};
|