@awebai/oats 0.22.12 → 0.22.16

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 CHANGED
@@ -16,11 +16,12 @@
16
16
  import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, readSync, realpathSync, rmSync, writeFileSync } from "node:fs";
17
17
  import { execFileSync, spawnSync } from "node:child_process";
18
18
  import { homedir, tmpdir } from "node:os";
19
- import { basename, dirname, join, resolve } from "node:path";
19
+ import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
20
+ import { createHash } from "node:crypto";
20
21
  import { fileURLToPath } from "node:url";
21
22
  import { enableTmuxMouse, tmuxConfigPath, tmuxMouseEnabled } from "../lib/tmux-config.mjs";
22
23
  import {
23
- LAYERS, LEGACY_HOME_CAPABILITIES_DIR, OATS_LOCK_FILE, OATS_VERSION, OAS_SCOPE_REMEDY, RETIRED_CAPABILITIES, detectOasScopes, retiredCapabilityReason, configChain,
24
+ LAYERS, LEGACY_HOME_CAPABILITIES_DIR, OATS_LOCK_FILE, OATS_VERSION, OAS_SCOPE_REMEDY, RETIRED_CAPABILITIES, detectOasScopes, retiredCapabilityReason, configChain, configCapabilityEntries, manifestOperations,
24
25
  acquireCapability, restoreCapabilities, marketplaceCapabilities,
25
26
  capabilityManifests, capabilityManifest, capabilityMissingRequires, capabilityIntegrity, capabilityTrust, capabilityExecutablePath,
26
27
  readCapabilityLocks, writeCapabilityLock,
@@ -41,8 +42,9 @@ import {
41
42
  } from "../lib/packages.mjs";
42
43
  import { attachArgv, checkRemote, forgetSnapshot, getServer, inspectRemote, startRemote, scheduleRemote, listSnapshots, readServers, rosterGroups, routeCommand, targetOf, validateServer, writeServers, SERVERS_FILE } from "../lib/servers.mjs";
43
44
  import { spawnSync as spawnSyncProc } from "node:child_process";
44
- import { scheduleScopeOf, listSchedules, describe as describeSchedule, addSchedule, updateSchedule, setEnabled as setScheduleEnabled, removeSchedule, runNow as runScheduleNow, reconcile as reconcileSchedule, tickHost, tickWorkspace, registerWorkspace, unregisterWorkspace, readRegistry, schedulerStatus, saveWakeForHome, removeWakeForHome, wakeFromFlags, withHostLock, scheduleError, SCHEDULE_API } from "../lib/schedule.mjs";
45
+ import { parseEnvelopeText, scheduleScopeOf, listSchedules, describe as describeSchedule, addSchedule, updateSchedule, setEnabled as setScheduleEnabled, removeSchedule, runNow as runScheduleNow, reconcile as reconcileSchedule, tickHost, tickWorkspace, registerWorkspace, unregisterWorkspace, readRegistry, schedulerStatus, saveWakeForHome, removeWakeForHome, wakeFromFlags, withHostLock, scheduleError, SCHEDULE_API } from "../lib/schedule.mjs";
45
46
  import { hostUnitStatus, installHostUnit, uninstallHostUnit } from "../lib/schedule-host.mjs";
47
+ import { receiveAttachment, uploadAttachment, readStreamBounded, MAX_ATTACHMENT_BYTES } from "../lib/attachments.mjs";
46
48
 
47
49
  const args = process.argv.slice(2);
48
50
  const cmd = args[0];
@@ -81,7 +83,7 @@ const JSON_MODE = args.includes("--json");
81
83
  // Canonical absolute path of this CLI executable — the versioned OATS_CLI_BIN
82
84
  // env contract for dispatched package commands (never resolved via PATH).
83
85
  const CLI_BIN = realpathSync(fileURLToPath(import.meta.url));
84
- const jsonFail = (code, message) => { console.log(JSON.stringify({ schemaVersion: 1, ok: false, error: { code, message: String(message) } })); process.exit(1); };
86
+ const jsonFail = (code, message, details) => { console.log(JSON.stringify({ schemaVersion: 1, ok: false, error: { code, message: String(message), ...(details !== undefined ? { details } : {}) } })); process.exit(1); };
85
87
  const jsonOk = (result) => { console.log(JSON.stringify({ schemaVersion: 1, ok: true, result })); };
86
88
 
87
89
  /** Level of a directory: laptop (home), repo (.git), else workspace. */
@@ -330,6 +332,558 @@ function doctorPackagesData(ctx, chain, { teamScope } = {}) {
330
332
  return { lockError: lockBroken, packages, legacyLockFiles, adoptedTemplates, missingHostRequirements, officialMigration: officialMigrationState(pkgLocks.legacy, { teamScope, ctx }) };
331
333
  }
332
334
 
335
+ // ---------- inspect: one authoritative answer for GUIs ----------
336
+ /** Souls, capabilities (installed state and health, separately from
337
+ * activation), effective layer bindings and declared operations for a
338
+ * scope, a selected soul, or a running home's snapshot. Read-only; the
339
+ * integrity scan runs only when asked (a GUI calls this on Refresh, never
340
+ * from its roster poll). Nothing here is provider-specific: what a
341
+ * knowledge provider offers is what its manifest declares. */
342
+ const INSPECT_TEXT_CAP = 256 * 1024;
343
+ function readTextCapped(file) {
344
+ let bytes;
345
+ try { bytes = readFileSync(file); }
346
+ catch (e) { return { file, text: null, sha256: null, truncated: false, error: `${e.code || "EIO"}: ${e.message}` }; }
347
+ const truncated = bytes.length > INSPECT_TEXT_CAP;
348
+ // The bound is bytes; a cut inside a multi-byte sequence is dropped, never
349
+ // rendered as a replacement character.
350
+ let text = truncated ? bytes.subarray(0, INSPECT_TEXT_CAP).toString("utf8") : bytes.toString("utf8");
351
+ if (truncated && text.endsWith("\uFFFD")) text = text.slice(0, -1);
352
+ return { file, text, sha256: createHash("sha256").update(bytes).digest("hex"), bytes: bytes.length, truncated, error: null };
353
+ }
354
+ /** The canonical agents root a home belongs to, from its path alone:
355
+ * <root>/<agent>/instances/<instance>, or <workspace>/local-agents/<agent>/
356
+ * instances/<instance> whose canonical root is the sibling agents/. */
357
+ function agentsRootOfHome(home) {
358
+ const agentDir = dirname(dirname(home));
359
+ const base = dirname(agentDir);
360
+ return basename(base) === "local-agents" ? join(dirname(base), "agents") : base;
361
+ }
362
+ const SOUL_FIELDS = ["runtime", "model", "yolo", "backend", "description"];
363
+ const realOrResolved = (p) => { try { return realpathSync(p); } catch { return resolve(p); } };
364
+ /** Every soul of a scope: persistent and local souls of every agents root in
365
+ * scope, plus packaged souls (read-only). One enumeration for inspect and
366
+ * operation run, so both address souls the same way. */
367
+ function scopeSouls(ctx, r, { extraRoots = [] } = {}) {
368
+ // A home's own agents root is always in scope for that home: its recorded
369
+ // work repository may be another repository entirely (repo overrides), and
370
+ // the config context resolves there while the soul lives with its owner.
371
+ const roots = [...new Set([...(r.team ? teamAgentRoots(r.team.scope) : [findRoot(ctx)]), ...extraRoots].filter(Boolean).map((p) => realOrResolved(resolve(p))))];
372
+ const souls = [];
373
+ for (const root of roots) {
374
+ for (const a of listInstances(root)) {
375
+ const soul = findAgent(root, a.name) || a;
376
+ const e = soulEntry(soul, root);
377
+ e.instances = (a.instances || []).map((i) => i.instance);
378
+ souls.push(e);
379
+ }
380
+ }
381
+ const diagnostics = [];
382
+ try {
383
+ const packaged = listCapabilityAgents(ctx);
384
+ diagnostics.push(...(packaged.diagnostics || []));
385
+ for (const pa of packaged) {
386
+ let soul = {};
387
+ try { soul = stripInternalAnnotations(withConfigFile(join(pa.soulDir, "soul.yaml"), () => parseYamlNested(readFileSync(join(pa.soulDir, "soul.yaml"), "utf8")))); } catch { /* reported by name only */ }
388
+ souls.push(soulEntry({ ...soul, name: pa.name, description: pa.description ?? soul.description, soulDir: pa.soulDir }, roots[0] || ctx, { capability: pa.capability }));
389
+ }
390
+ } catch (e) { diagnostics.push({ code: e.code || "E_CAPABILITY_BROKEN", message: e.message }); }
391
+ return { roots, souls, diagnostics };
392
+ }
393
+ /** The one soul a name (and optional agents root) addresses; throws with a
394
+ * code when none or several match. */
395
+ function selectSoul(souls, name, agentsRoot, ctx) {
396
+ let matches = souls.filter((s) => s.name === name);
397
+ if (agentsRoot) matches = matches.filter((s) => realOrResolved(s.agentsRoot) === realOrResolved(agentsRoot));
398
+ if (!matches.length) throw Object.assign(new Error(`no soul ${JSON.stringify(name)} in the scope of ${ctx}${agentsRoot ? ` under ${agentsRoot}` : ""}`), { code: "E_SOUL_UNKNOWN" });
399
+ // Same-named souls under several member roots: the member the caller
400
+ // addressed with --dir breaks the tie; a team root or an unrelated
401
+ // directory does not, and the answer names --agents-root as the remedy.
402
+ if (matches.length > 1) {
403
+ const here = realOrResolved(ctx);
404
+ const own = matches.filter((s) => s.kind !== "capability" && realOrResolved(dirname(s.agentsRoot)) === here);
405
+ if (own.length === 1) return own[0];
406
+ throw Object.assign(new Error(`soul ${JSON.stringify(name)} exists under ${matches.length} agents roots (${matches.map((m) => m.agentsRoot).join(", ")}); pass --agents-root <abs>`), { code: "E_SOUL_AMBIGUOUS" });
407
+ }
408
+ return matches[0];
409
+ }
410
+ /** The config context a selected soul belongs to: the workspace of its
411
+ * agents root (a member repository under a team scope). An explicit --dir
412
+ * may be that context or a scope enclosing it (the team root); another
413
+ * member's context contradicts the selection and is refused. */
414
+ function memberContextOf(soul, ctx, explicitDir, bail) {
415
+ if (soul.kind === "capability") return realOrResolved(ctx);
416
+ const member = realOrResolved(dirname(soul.agentsRoot));
417
+ const given = realOrResolved(ctx);
418
+ if (member === given) return member;
419
+ if (explicitDir && !member.startsWith(given + sep)) bail("E_SCOPE_MISMATCH", `--dir ${ctx} is not the scope of soul ${soul.name} under ${soul.agentsRoot} (${member}); pass that member's directory, an enclosing team scope, or omit --dir`);
420
+ return member;
421
+ }
422
+ /** A home's context for --dir validation: its recorded repository, or the
423
+ * workspace that holds its agents root (what a roster derives). */
424
+ function homeContexts(home, meta) {
425
+ const out = [];
426
+ if (meta.repo && existsSync(meta.repo)) out.push(resolve(meta.repo));
427
+ out.push(dirname(agentsRootOfHome(realOrResolved(home))));
428
+ return out;
429
+ }
430
+ function soulEntry(soul, root, { capability } = {}) {
431
+ const dir = soul._dir || soul.soulDir;
432
+ const soulDir = capability ? soul.soulDir : join(dir, "soul");
433
+ const packaged = !!capability;
434
+ return {
435
+ name: soul.name, kind: packaged ? "capability" : (soul.kind || "persistent"), capability: capability || null,
436
+ type: soul.type ?? null, description: soul.description ?? null, repo: soul.repo ?? null, work: soul.work || "checkout",
437
+ runtime: soul.runtime || "pi", model: soul.model ?? null, yolo: soul.yolo === true || soul.yolo === "true" ? true : soul.yolo === false || soul.yolo === "false" ? false : null, backend: soul.backend ?? null,
438
+ agentsRoot: root, dir: packaged ? soulDir : dir, soulFile: join(soulDir, "soul.yaml"), instructionsFile: join(soulDir, "AGENTS.md"),
439
+ editable: packaged
440
+ ? { fields: [], instructions: false, reason: `packaged soul from capability ${capability}: edit the package and update it; scoped bindings still apply through oats use` }
441
+ : { fields: [...SOUL_FIELDS], instructions: true, reason: null },
442
+ instances: [],
443
+ };
444
+ }
445
+ /** These commands address a scope explicitly (--dir, --home, cwd); the
446
+ * invoking process's ambient agents-root override must not redirect them
447
+ * to its own deployment. */
448
+ function dropAmbientRoot() { delete process.env.PI_AGENTS_ROOT; }
449
+ function inspectCmd() {
450
+ const bail = (code, msg) => (JSON_MODE ? jsonFail(code, msg) : die(msg));
451
+ dropAmbientRoot();
452
+ const homeFlag = flag("home");
453
+ const home = homeFlag === true ? bail("E_BAD_ARGS", "--home needs an absolute instance home") : homeFlag;
454
+ let meta;
455
+ if (home) {
456
+ if (!isAbsolute(home)) bail("E_BAD_ARGS", "--home needs an absolute instance home");
457
+ const metaFile = join(home, "instance.json");
458
+ if (!existsSync(metaFile)) bail("E_SESSION_UNKNOWN", `${home} is not an OATS instance home (no instance.json)`);
459
+ try { meta = JSON.parse(readFileSync(metaFile, "utf8")); } catch (e) { bail("E_SESSION_UNKNOWN", `${metaFile}: ${e.message}`); }
460
+ }
461
+ const real = realOrResolved;
462
+ let ctx;
463
+ if (meta) {
464
+ // The home is the identity and its recorded repository is ALWAYS its
465
+ // context (that is what composed it); an explicit --dir is accepted only
466
+ // as an alias naming that repository or the workspace of the home's
467
+ // agents root, and never replaces the context.
468
+ const contexts = homeContexts(home, meta);
469
+ if (flag("dir") !== undefined) { const given = dirFlag(); if (!contexts.some((c) => real(c) === real(given))) bail("E_HOME_MISMATCH", `--dir ${given} is not the context of ${home} (${contexts.join(" or ")}); omit --dir for a home`); }
470
+ ctx = contexts[0];
471
+ } else ctx = dirFlag();
472
+ const soulFlag = flag("soul");
473
+ if (soulFlag === true) bail("E_BAD_ARGS", "--soul needs a soul name");
474
+ if (meta && soulFlag && soulFlag !== meta.agent) bail("E_HOME_MISMATCH", `--soul ${soulFlag} is not the soul of ${home} (${meta.agent})`);
475
+ const soulName = soulFlag || meta?.agent || undefined;
476
+ let agentsRootFlag = flag("agents-root");
477
+ if (agentsRootFlag === true) bail("E_BAD_ARGS", "--agents-root needs an absolute agents directory");
478
+ if (meta) {
479
+ // The soul is the home's own, under the home's own root; same-named souls
480
+ // in other member repositories are ordinary and never ambiguous here.
481
+ const homeRoot = agentsRootOfHome(real(home));
482
+ if (agentsRootFlag && real(agentsRootFlag) !== real(homeRoot)) bail("E_HOME_MISMATCH", `--agents-root ${agentsRootFlag} is not the agents root of ${home} (${homeRoot})`);
483
+ agentsRootFlag = homeRoot;
484
+ }
485
+ let r;
486
+ try { r = resolveOatsConfig(ctx, soulName); } catch (e) { bail(e.code || "E_CONFIG_BROKEN", e.message); }
487
+ let chain = configChain(ctx);
488
+ const enumerated = scopeSouls(ctx, r, { extraRoots: meta ? [agentsRootOfHome(realOrResolved(home))] : [] });
489
+ const roots = enumerated.roots;
490
+ let souls = enumerated.souls;
491
+ const packagedDiagnostics = enumerated.diagnostics;
492
+ let selectedSoul = null;
493
+ const requestedContext = ctx;
494
+ if (soulName) {
495
+ try { selectedSoul = selectSoul(souls, soulName, agentsRootFlag, ctx); } catch (e) { bail(e.code || "E_SOUL_UNKNOWN", e.message); }
496
+ selectedSoul.instructions = readTextCapped(selectedSoul.instructionsFile);
497
+ souls = [selectedSoul];
498
+ // A soul's effective bindings are its own member's: a team root or
499
+ // another member's --dir must not be applied to it. (A home keeps its
500
+ // recorded repository as its context; that is what composed it.)
501
+ if (!meta) {
502
+ const member = memberContextOf(selectedSoul, ctx, flag("dir") !== undefined, bail);
503
+ if (member !== realOrResolved(ctx)) {
504
+ ctx = member;
505
+ try { r = resolveOatsConfig(ctx, soulName); } catch (e) { bail(e.code || "E_CONFIG_BROKEN", e.message); }
506
+ chain = configChain(ctx);
507
+ }
508
+ }
509
+ }
510
+
511
+ // Capabilities: installed state and health from the package engine (exactly
512
+ // what `oats list` reports), owned/path manifests beside them, and the
513
+ // ACTIVATION for the selected soul (or global) from the resolver.
514
+ const mans = capabilityManifests(ctx);
515
+ let lockError = null;
516
+ const byId = new Map();
517
+ try {
518
+ const pkgs = listInstalledPackages(ctx), locks = readPackageLocks(ctx);
519
+ for (const p of pkgs) {
520
+ const rows = levelRows(locks, p.level);
521
+ for (const c of p.capabilities) {
522
+ const h = capabilityHealth(p.level, c, rows.capabilities[c.id], rows.packages[p.package]);
523
+ byId.set(c.id, {
524
+ id: c.id, package: p.package, version: c.version || null, layer: c.manifest?.layer || null, command: c.manifest?.command || null,
525
+ origin: "installed", level: p.level, source: p.source || null, dir: h.dir,
526
+ health: { status: h.status, code: h.code, detail: h.detail, installed: !!c.installed, locked: true, trusted: c.trusted === true, integrity: c.integrity || null, installedIntegrity: h.integrity ?? null },
527
+ });
528
+ }
529
+ }
530
+ } catch (e) { lockError = { code: e.code || "invalid-lock", message: e.message }; }
531
+ for (const [id, m] of Object.entries(mans)) {
532
+ if (byId.has(id)) continue;
533
+ const trust = capabilityTrust(m, ctx);
534
+ const executable = Object.keys(m.commands || {}).length || Object.keys(m.hooks || {}).length || (m.environment?.length || 0);
535
+ let integrity = trust.integrity || null;
536
+ if (!integrity) { try { integrity = capabilityArtifactIntegrity(m._dir); } catch { integrity = null; } }
537
+ byId.set(id, {
538
+ id, package: m._package || null, version: m.version || null, layer: m.layer || null, command: m.command || null,
539
+ origin: String(m._origin || "").split(":")[0] || "unknown", level: String(m._origin || "").split(":").slice(1).join(":") || null, source: null, dir: m._dir,
540
+ health: { status: executable && !trust.trusted ? "untrusted" : "ok", code: executable && !trust.trusted ? "untrusted-surface" : null, detail: executable && !trust.trusted ? (trust.reason || null) : null, installed: true, locked: !!trust.lock, trusted: !!trust.trusted, integrity, installedIntegrity: integrity },
541
+ });
542
+ }
543
+ // What is EFFECTIVE for the answer: a home's captured bindings and settings
544
+ // (with the currently acquired manifests and current trust); a soul's or
545
+ // scope's current config otherwise. The current config is reported
546
+ // separately for a home so a GUI can show both without confusing them.
547
+ const snapshotCaps = meta ? (meta.capabilities || []) : null;
548
+ const layerIdOf = (rec) => { const m = typeof rec === "string" ? /^([a-z0-9][a-z0-9._-]*)(?:\s|$)/.exec(rec) : null; return m && m[1] !== "none" ? m[1] : null; };
549
+ const effectiveLayers = meta
550
+ ? Object.fromEntries(LAYERS.map((l) => { const id = layerIdOf(meta.layers?.[l]) || snapshotCaps.find((c) => mans[c.id]?.layer === l)?.id || null; const rec = typeof meta.layers?.[l] === "string" ? meta.layers[l] : null; return [l, { id, level: snapshotCaps.find((c) => c.id === id)?.level || null, provenance: rec, disabled: !id && !!rec && rec.startsWith("none") }]; }))
551
+ : Object.fromEntries(LAYERS.map((l) => [l, r.layers[l]
552
+ ? { id: r.layers[l].id, level: r.layers[l].level, provenance: r.provenance[l] || null, disabled: false }
553
+ : { id: null, level: r.layerDisabled?.[l]?.level || null, provenance: r.provenance[l] || null, disabled: !!r.layerDisabled?.[l] }]));
554
+ const effectiveActive = (id) => meta
555
+ ? (() => { const c = snapshotCaps.find((x) => x.id === id); return c ? { id, level: c.level || null, provenance: c.provenance || [], settings: c.settings || {} } : undefined; })()
556
+ : r.capabilities.find((c) => c.id === id);
557
+ const declaredAt = (id) => chain.flatMap((cfg) => configCapabilityEntries(cfg).filter((e) => e.id === id).map((e) => ({ level: cfg._level, slot: e.slot || null, targets: [
558
+ ...(e.spec.global !== undefined ? [`global`] : []),
559
+ ...Object.keys(e.spec["agent-types"] || {}).map((t) => `type:${t}`),
560
+ ...Object.keys(e.spec.souls || {}).map((sn) => `soul:${sn}`),
561
+ ] })));
562
+ const targetOf = (provenance) => [...provenance].map((p) => p.split(" @ ")[0]).sort((a, b) => (b.startsWith("soul:") ? 2 : b.startsWith("type:") ? 1 : 0) - (a.startsWith("soul:") ? 2 : a.startsWith("type:") ? 1 : 0))[0] || (meta ? "snapshot" : "global");
563
+ const capabilities = [...byId.values()].sort((a, b) => a.id.localeCompare(b.id)).map((entry) => {
564
+ const active = effectiveActive(entry.id);
565
+ const m = mans[entry.id];
566
+ const missingRequires = (() => { try { return capabilityMissingRequires(entry.id, ctx).map((x) => ({ command: x.command, why: x.why || null, install: x.install || null })); } catch { return []; } })();
567
+ const disabledLayer = entry.layer && (meta ? (effectiveLayers[entry.layer]?.disabled ? { level: null } : null) : r.layerDisabled?.[entry.layer]);
568
+ const declared = declaredAt(entry.id);
569
+ const activation = active
570
+ ? { enabled: true, source: meta ? "snapshot" : "config", target: targetOf(active.provenance || []), level: active.level, provenance: active.provenance || [], settings: active.settings || {}, declaredAt: declared }
571
+ : { enabled: false, source: meta ? "snapshot" : "config", target: declared.length ? "declared" : "none", level: declared[0]?.level || null, provenance: [], settings: {}, declaredAt: declared, ...(disabledLayer ? { reason: `layer ${entry.layer} is disabled${disabledLayer.level ? ` at ${disabledLayer.level}` : " for this home"}` } : {}) };
572
+ const operations = manifestOperations(m).map((op) => {
573
+ let reason = null;
574
+ if (!active) reason = disabledLayer ? `layer ${entry.layer} is disabled${disabledLayer.level ? ` at ${disabledLayer.level}` : " for this home"}` : `${entry.id} is not activated for ${meta ? `home ${basename(home)}` : soulName ? `soul ${soulName}` : "this scope"}`;
575
+ else if (!entry.health.trusted) reason = `${entry.id} executable surface is not trusted (oats trust ${entry.id})`;
576
+ else if (entry.health.status !== "ok") reason = entry.health.detail || entry.health.status;
577
+ else if (missingRequires.length) reason = `${entry.id} requires ${missingRequires.map((x) => `"${x.command}" on PATH${x.why ? ` (${x.why})` : ""}`).join(", ")}`;
578
+ else if (op.context === "home" && !home) reason = "needs a running home (--home)";
579
+ return { ...op, argv: [entry.command, op.command], available: !reason, reason };
580
+ });
581
+ return { ...entry, missingRequires, activation, operations };
582
+ });
583
+ const layers = effectiveLayers;
584
+ // For a home, the CURRENT config beside the captured bindings, so a GUI can
585
+ // show what future instances would get without mistaking it for the home's.
586
+ const currentConfig = meta ? {
587
+ layers: Object.fromEntries(LAYERS.map((l) => [l, r.layers[l] ? { id: r.layers[l].id, level: r.layers[l].level, provenance: r.provenance[l] || null, disabled: false } : { id: null, level: r.layerDisabled?.[l]?.level || null, provenance: r.provenance[l] || null, disabled: !!r.layerDisabled?.[l] }])),
588
+ activations: r.capabilities.map((c) => ({ id: c.id, target: targetOf(c.provenance), level: c.level, settings: c.settings || {} })),
589
+ } : null;
590
+ const knowledgeCap = layers.knowledge.id ? capabilities.find((c) => c.id === layers.knowledge.id) : null;
591
+ const knowledge = knowledgeCap ? { provider: knowledgeCap.id, version: knowledgeCap.version, operations: knowledgeCap.operations.map((o) => ({ name: o.name, kind: o.kind, available: o.available, reason: o.reason })) } : { provider: null, version: null, operations: [] };
592
+
593
+ let snapshot = null;
594
+ if (meta) {
595
+ const runtimeById = new Map((meta.capabilityRuntime || []).map((c) => [c.id, c]));
596
+ const drift = [];
597
+ for (const c of meta.capabilities || []) {
598
+ const now = r.capabilities.find((x) => x.id === c.id);
599
+ if (!now) { drift.push({ id: c.id, field: "activation", snapshot: true, config: false }); continue; }
600
+ if (JSON.stringify(c.settings || {}) !== JSON.stringify(now.settings || {})) drift.push({ id: c.id, field: "settings", snapshot: c.settings || {}, config: now.settings || {} });
601
+ const then = runtimeById.get(c.id)?.trust?.integrity, cur = byId.get(c.id)?.health?.integrity;
602
+ if (then && cur && then !== cur) drift.push({ id: c.id, field: "integrity", snapshot: then, config: cur });
603
+ }
604
+ for (const now of r.capabilities) if (!(meta.capabilities || []).some((c) => c.id === now.id)) drift.push({ id: now.id, field: "activation", snapshot: false, config: true });
605
+ snapshot = {
606
+ home, instance: meta.instance, agent: meta.agent, runtime: meta.runtime || null, model: meta.model ?? null, yolo: meta.yolo ?? null, launched: !!meta.launched, createdAt: meta.createdAt || null,
607
+ layers: meta.layers || {}, capabilities: (meta.capabilities || []).map((c) => ({ id: c.id, level: c.level, settings: c.settings || {}, trusted: runtimeById.get(c.id)?.trust?.trusted ?? null })),
608
+ instructions: { ...readTextCapped(join(home, "AGENTS.md")), sources: meta.instructions || [] }, drift,
609
+ };
610
+ }
611
+ const result = {
612
+ operationsApi: 1, kernel: OATS_VERSION,
613
+ scope: { context: ctx, requestedContext: requestedContext === ctx ? null : requestedContext, workspace: roots.length ? workspaceOf(roots[0]) : ctx, team: r.team || null, chain: chain.map((c) => ({ file: c._file, level: c._level, levelKind: levelOf(c._level) })), agentsRoots: roots },
614
+ selected: { soul: selectedSoul?.name || null, agentsRoot: selectedSoul?.agentsRoot || null, home: home || null, source: meta ? "snapshot" : "config" },
615
+ souls, layers, capabilities, knowledge, snapshot, currentConfig,
616
+ problems: [...(lockError ? [lockError] : []), ...packagedDiagnostics.map((d) => ({ code: d.code, message: d.message, capability: d.capability })),
617
+ ...(meta ? snapshotCaps.filter((c) => !mans[c.id]).map((c) => ({ code: "captured-capability-missing", message: `${c.id} was active when this home was composed but no manifest for it is acquired now`, capability: c.id })) : [])],
618
+ };
619
+ if (JSON_MODE) { jsonOk(result); return; }
620
+ console.log(`oats inspect — ${shortPath(ctx)}${selectedSoul ? ` soul ${selectedSoul.name}` : ""}${home ? ` home ${shortPath(home)}` : ""}`);
621
+ for (const s of souls) console.log(` soul ${s.name} [${s.kind}${s.capability ? ` ${s.capability}` : ""}] runtime ${s.runtime}${s.model ? ` model ${s.model}` : ""} work ${s.work}${s.editable.fields.length ? "" : " (read-only)"}`);
622
+ for (const l of LAYERS) console.log(` layer ${l}: ${layers[l].id || (layers[l].disabled ? "disabled" : "none")}${layers[l].provenance ? ` (${layers[l].provenance})` : ""}`);
623
+ for (const c of capabilities) console.log(` ${c.id}@${c.version || "?"} ${c.health.status}${c.activation.enabled ? ` active:${c.activation.target}` : " inactive"}${c.operations.length ? ` ops: ${c.operations.map((o) => `${o.name}${o.available ? "" : "(unavailable)"}`).join(", ")}` : ""}`);
624
+ for (const p of result.problems) console.log(` ! ${p.code}: ${p.message}`);
625
+ }
626
+
627
+ // ---------- operation run: generic invoke through the capability engine ----------
628
+ /** `oats operation run <layer>:<name>`: resolve the provider that fills
629
+ * <layer> for a running home (its snapshot) or for a soul in a scope (the
630
+ * config), require the operation to be declared and the executable surface
631
+ * trusted, then run the provider's own command exactly as `oats <ns> <cmd>`
632
+ * would, in the home (context home) or the scope (context scope), and relay
633
+ * its envelope. A view operation must answer { documents: [...] }. No
634
+ * provider name appears here. */
635
+ const OPERATION_ADDRESS_RE = /^(knowledge|messaging|tasks):([a-z][a-z0-9-]*)$/;
636
+ /** The same phrasing the scheduler treats as retained effects. */
637
+ const reportsRetainedEffectsText = (message) => /INCOMPLETE|quarantin|retain|could not (?:be )?(?:verif|confirm)/i.test(String(message || ""));
638
+ // Comfortably below the scheduler's 5-minute command bound and any GUI
639
+ // proxy, so the receipt always reaches the caller before a wrapper gives up.
640
+ const OPERATION_TIMEOUT_MS = 4 * 60 * 1000;
641
+ function operationCmd() {
642
+ const bail = (code, msg, details) => (JSON_MODE ? jsonFail(code, msg, details) : die(msg));
643
+ dropAmbientRoot();
644
+ if (args[1] !== "run") bail("E_USAGE", "usage: oats operation run <layer>:<name> (--home <abs> | --soul <name> [--dir <scope>] [--agents-root <abs>]) [--arg k=v ...] [--json]");
645
+ const address = args[2];
646
+ const m0 = typeof address === "string" ? OPERATION_ADDRESS_RE.exec(address) : null;
647
+ if (!m0) bail("E_BAD_ARGS", `operation address must be <layer>:<name> with layer one of ${LAYERS.join(", ")} (got ${JSON.stringify(address)})`);
648
+ const [, layer, opName] = m0;
649
+ const homeFlag = flag("home");
650
+ if (homeFlag === true) bail("E_BAD_ARGS", "--home needs an absolute instance home");
651
+ const home = homeFlag ? resolve(homeFlag) : undefined;
652
+ let meta;
653
+ if (home) {
654
+ if (!isAbsolute(homeFlag)) bail("E_BAD_ARGS", "--home needs an absolute instance home");
655
+ const metaFile = join(home, "instance.json");
656
+ if (!existsSync(metaFile)) bail("E_SESSION_UNKNOWN", `${home} is not an OATS instance home (no instance.json)`);
657
+ try { meta = JSON.parse(readFileSync(metaFile, "utf8")); } catch (e) { bail("E_SESSION_UNKNOWN", `${metaFile}: ${e.message}`); }
658
+ }
659
+ // The home is the identity: an explicit --dir must be one of its own
660
+ // contexts, never a different scope's config applied to it.
661
+ let ctx;
662
+ if (meta) {
663
+ // The recorded repository is always a home's context; --dir is only an
664
+ // alias to validate (the repository or the workspace of the home's root).
665
+ const contexts = homeContexts(home, meta);
666
+ if (flag("dir") !== undefined) { const given = dirFlag(); if (!contexts.some((c) => realOrResolved(c) === realOrResolved(given))) bail("E_HOME_MISMATCH", `--dir ${given} is not the context of ${home} (${contexts.join(" or ")}); omit --dir for a home`); }
667
+ ctx = contexts[0];
668
+ } else ctx = dirFlag();
669
+ const soulFlag = flag("soul");
670
+ if (soulFlag === true) bail("E_BAD_ARGS", "--soul needs a soul name");
671
+ if (meta && soulFlag && soulFlag !== meta.agent) bail("E_HOME_MISMATCH", `--soul ${soulFlag} is not the soul of ${home} (${meta.agent})`);
672
+ const soulName = soulFlag || meta?.agent || undefined;
673
+ let agentsRootFlag = flag("agents-root");
674
+ if (agentsRootFlag === true) bail("E_BAD_ARGS", "--agents-root needs an absolute agents directory");
675
+ if (meta) {
676
+ const homeRoot = agentsRootOfHome(realOrResolved(home));
677
+ if (agentsRootFlag && realOrResolved(agentsRootFlag) !== realOrResolved(homeRoot)) bail("E_HOME_MISMATCH", `--agents-root ${agentsRootFlag} is not the agents root of ${home} (${homeRoot})`);
678
+ agentsRootFlag = homeRoot;
679
+ }
680
+ // The same soul selection as inspect: name plus agents root, refused when
681
+ // ambiguous, never silently the first match.
682
+ let selectedSoul;
683
+ if (soulName) {
684
+ let rSel;
685
+ try { rSel = resolveOatsConfig(ctx, meta ? undefined : soulName); } catch (e) { bail(e.code || "E_CONFIG_BROKEN", e.message); }
686
+ try { selectedSoul = selectSoul(scopeSouls(ctx, rSel, { extraRoots: meta ? [agentsRootOfHome(realOrResolved(home))] : [] }).souls, soulName, agentsRootFlag, ctx); } catch (e) { bail(e.code || "E_SOUL_UNKNOWN", e.message); }
687
+ // The provider and its settings are the selected soul's own member's,
688
+ // never a team root's or another member's (a home keeps its recorded
689
+ // repository as its context).
690
+ if (!meta) ctx = memberContextOf(selectedSoul, ctx, flag("dir") !== undefined, bail);
691
+ }
692
+ // --arg k=v pairs, matched against the operation's declared args below.
693
+ const given = Object.create(null);
694
+ for (let i = 3; i < args.length; i++) {
695
+ if (args[i] !== "--arg") continue;
696
+ const kv = args[i + 1];
697
+ if (!kv || kv.startsWith("--") || !kv.includes("=")) bail("E_BAD_ARGS", "--arg expects name=value");
698
+ const eq = kv.indexOf("=");
699
+ given[kv.slice(0, eq)] = kv.slice(eq + 1);
700
+ i++;
701
+ }
702
+ // Provider resolution: the snapshot's active capabilities for a home, the
703
+ // config for a soul/scope.
704
+ const mans = capabilityManifests(ctx);
705
+ let provider, settings, team, disabled = null;
706
+ if (meta) {
707
+ const ids = (meta.capabilities || []).map((c) => c.id);
708
+ const id = ids.find((cid) => mans[cid]?.layer === layer);
709
+ provider = id ? mans[id] : undefined;
710
+ settings = (meta.capabilities || []).find((c) => c.id === id)?.settings || {};
711
+ team = meta.team || (() => { try { return resolveOatsConfig(ctx).team; } catch { return undefined; } })();
712
+ if (!provider) { const rec = meta.layers?.[layer]; disabled = typeof rec === "string" && rec.startsWith("none") ? rec : null; }
713
+ } else {
714
+ let r;
715
+ try { r = resolveOatsConfig(ctx, soulName); } catch (e) { bail(e.code || "E_CONFIG_BROKEN", e.message); }
716
+ provider = r.layers[layer] ? mans[r.layers[layer].id] : undefined;
717
+ settings = r.layers[layer]?.settings || {};
718
+ team = r.team;
719
+ if (!provider && r.layerDisabled?.[layer]) disabled = `none @ ${r.layerDisabled[layer].level}`;
720
+ }
721
+ if (!provider) bail("E_OPERATION_UNAVAILABLE", disabled ? `layer ${layer} is explicitly disabled (${disabled}); no provider can run ${address}` : `no ${layer} provider is active for ${meta ? home : soulName ? `soul ${soulName} in ${ctx}` : ctx}`);
722
+ const op = manifestOperations(provider).find((o) => o.name === opName);
723
+ if (!op) bail("E_OPERATION_UNKNOWN", `${provider.capability} declares no operation ${JSON.stringify(opName)} (declared: ${manifestOperations(provider).map((o) => o.name).join(", ") || "none"})`);
724
+ const trust = capabilityTrust(provider, ctx);
725
+ if (!trust.trusted) bail("E_CAPABILITY_BLOCKED", `${provider.capability} executable surface is blocked: ${trust.reason || "not trusted"} (oats trust ${provider.capability})`);
726
+ const missingReq = capabilityMissingRequires(provider.capability, ctx);
727
+ if (missingReq.length) bail("E_CAPABILITY_REQUIRES", `${provider.capability} requires ${missingReq.map((m) => `"${m.command}" on PATH${m.why ? ` (${m.why})` : ""}${m.install ? ` [install: ${m.install}]` : ""}`).join(", ")}; ${address} was not run`);
728
+ if (op.context === "home" && !meta) bail("E_OPERATION_UNAVAILABLE", `${address} runs in an instance home; pass --home <abs>`);
729
+ const declared = new Map(op.args.map((a) => [a.name, a]));
730
+ for (const name of Object.keys(given)) if (!declared.has(name)) bail("E_BAD_ARGS", `${address} takes no arg ${JSON.stringify(name)} (declared: ${[...declared.keys()].join(", ") || "none"})`);
731
+ for (const a of op.args) if (a.required && given[a.name] === undefined) bail("E_BAD_ARGS", `${address} needs --arg ${a.name}=<value>: ${a.description || "required"}`);
732
+ const argFlags = op.args.flatMap((a) => (given[a.name] === undefined ? [] : [a.flag, given[a.name]]));
733
+ const spec = provider.commands[op.command];
734
+ if (typeof spec !== "string" || !spec.trim()) bail("E_CAPABILITY_BROKEN", `${provider.capability}: command ${op.command} is not a non-empty string`);
735
+ const [script, ...rest] = spec.trim().split(/\s+/);
736
+ let abs;
737
+ try { abs = capabilityExecutablePath(provider, script); } catch (e) { bail("E_CAPABILITY_BROKEN", e.message); }
738
+ if (!abs) bail("E_CAPABILITY_BROKEN", `${provider.capability} ${op.command}: script not found (${join(provider._dir, script)})`);
739
+ const cwd = op.context === "home" ? home : ctx;
740
+ // The provider sees exactly the selected target: identity and context
741
+ // variables are SET for it (a home, or a soul in a scope) and every
742
+ // ambient one from the invoking process is removed, so a coordinator
743
+ // running this for another home never steers the provider to its own.
744
+ const env = { ...process.env };
745
+ for (const k of ["OATS_EVENT", "OATS_INSTANCE", "OATS_INSTANCE_HOME", "OATS_HOME", "OATS_AGENT", "OATS_SOUL", "OATS_CONTEXT", "OATS_ROOT", "OATS_WORKSPACE", "OATS_LEVEL", "OATS_META", "OATS_KIND", "PI_AGENT_INSTANCE", "PI_AGENT_HOME", "PI_AGENTS_ROOT"]) delete env[k];
746
+ const targetRoot = meta ? agentsRootOfHome(realOrResolved(home)) : selectedSoul?.agentsRoot;
747
+ const soulDir = meta ? join(dirname(dirname(realOrResolved(home))), "soul") : selectedSoul ? dirname(selectedSoul.soulFile) : undefined;
748
+ Object.assign(env, {
749
+ OATS_CAPABILITY: provider.capability, OATS_SETTINGS: JSON.stringify(settings || {}), OATS_CLI_BIN: CLI_BIN, OATS_OPERATION: address,
750
+ OATS_CONTEXT: ctx, OATS_WORKSPACE: targetRoot ? workspaceOf(targetRoot) : workspaceOf(findRoot(ctx) || ctx),
751
+ OATS_TEAM_NAME: team?.name || "", OATS_TEAM_ID: team?.id || "", OATS_TEAM_SCOPE: team?.scope || "",
752
+ ...(targetRoot ? { OATS_ROOT: targetRoot, PI_AGENTS_ROOT: targetRoot } : {}),
753
+ ...(soulName ? { OATS_AGENT: soulName } : {}), ...(soulDir ? { OATS_SOUL: soulDir } : {}),
754
+ });
755
+ if (op.context === "home") Object.assign(env, { OATS_INSTANCE: meta.instance, OATS_INSTANCE_HOME: home, OATS_HOME: home, PI_AGENT_INSTANCE: meta.instance, PI_AGENT_HOME: home });
756
+ const r = spawnSync("node", [abs, ...rest, ...argFlags, "--json"], { cwd, env, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 16 * 1024 * 1024, timeout: OPERATION_TIMEOUT_MS, killSignal: "SIGTERM" });
757
+ const stderr = String(r.stderr || "").trim();
758
+ const timedOut = r.error?.code === "ETIMEDOUT" || (r.status === null && r.signal === "SIGTERM");
759
+ if (r.error && !timedOut) bail("E_CAPABILITY_BROKEN", `${address}: ${r.error.message || r.error}`);
760
+ const base = { operation: address, capability: provider.capability, version: provider.version || null, argv: [provider.command, op.command, ...argFlags], cwd, target: home ? { home, instance: meta.instance } : null };
761
+ // Unconfirmed outcomes (a timeout, no valid receipt, a receipt contradicted
762
+ // by the exit status) carry what WAS observed in error.details, so a
763
+ // scheduler can keep the slot as unknown and reconcile by any name the
764
+ // provider managed to answer; they are never confirmed failures.
765
+ const observed = (envelope) => ({ exit: r.status, signal: r.signal || null, unconfirmed: true, ...(envelope && typeof envelope === "object" ? { envelope } : {}), ...(stderr ? { stderr: stderr.slice(0, 2000) } : {}) });
766
+ if (timedOut) bail("E_OPERATION_TIMEOUT", `${address} (${provider.capability} ${op.command}) did not finish within ${OPERATION_TIMEOUT_MS / 1000} s; its effects are unconfirmed`, observed(parseEnvelopeText(String(r.stdout || ""))));
767
+ // Exactly one JSON-v1 envelope on stdout, nothing else, and an exit status
768
+ // that agrees with it: contaminated output or a success envelope from a
769
+ // process that then failed is not a receipt.
770
+ let envelope;
771
+ try { envelope = JSON.parse(String(r.stdout || "").trim()); } catch { envelope = undefined; }
772
+ if (!envelope || typeof envelope !== "object" || Array.isArray(envelope) || envelope.schemaVersion !== 1 || typeof envelope.ok !== "boolean") bail("E_OPERATION_RESULT", `${address} (${provider.capability} ${op.command}) did not answer exactly one JSON-v1 envelope on stdout (exit ${r.status}); its effects are unconfirmed${stderr ? `: ${stderr.slice(0, 400)}` : ""}`, observed(parseEnvelopeText(String(r.stdout || ""))));
773
+ // A provider's own failure is relayed with its code; its WHOLE envelope
774
+ // (a partial receipt such as result.instance of something it launched
775
+ // before failing, and any details it gave) travels in error.details so a
776
+ // scheduler can keep an unconfirmed outcome and reconcile that target.
777
+ if (!envelope.ok) bail(envelope.error?.code || "E_OPERATION_FAILED", `${address}: ${envelope.error?.message || "failed"}`, { exit: r.status, envelope, ...(reportsRetainedEffectsText(envelope.error?.message) ? { unconfirmed: true } : {}) });
778
+ if (r.status !== 0) bail("E_OPERATION_RESULT", `${address} (${provider.capability} ${op.command}) answered ok but exited ${r.status}; the receipt is not trusted and its effects are unconfirmed${stderr ? `: ${stderr.slice(0, 400)}` : ""}`, observed(envelope));
779
+ const result = envelope.result && typeof envelope.result === "object" ? envelope.result : {};
780
+ if (op.kind === "view") {
781
+ const docs = result.documents;
782
+ const bad = !Array.isArray(docs) || docs.some((d) => !d || typeof d !== "object" || typeof d.label !== "string" || !d.label
783
+ || (d.kind !== undefined && !["markdown", "text"].includes(d.kind))
784
+ || (d.path !== undefined && d.path !== null && (typeof d.path !== "string" || !isAbsolute(d.path)))
785
+ || (d.text !== undefined && d.text !== null && typeof d.text !== "string"));
786
+ if (bad) bail("E_OPERATION_RESULT", `${address} is a view operation but ${provider.capability} ${op.command} did not answer { documents: [{label, kind?, path?, text?}] }`);
787
+ }
788
+ // A launch receipt in the delegated result (a harvester the provider
789
+ // spawned) is surfaced as top-level instance/home so the scheduler tracks
790
+ // it exactly as it tracks a command job's launch; the source home itself
791
+ // is `target`, never a launch.
792
+ const receipt = {};
793
+ if (typeof result.instance === "string" && result.instance !== meta?.instance) receipt.instance = result.instance;
794
+ if (typeof result.home === "string" && result.home !== home) receipt.home = result.home;
795
+ const out = { ...base, ...receipt, result, ...(stderr ? { stderr: stderr.slice(0, 2000) } : {}) };
796
+ if (JSON_MODE) { jsonOk(out); return; }
797
+ console.log(`${address} via ${provider.capability}@${provider.version || "?"} (${provider.command} ${op.command}) in ${shortPath(cwd)}: ok`);
798
+ if (op.kind === "view") for (const d of result.documents) console.log(` - ${d.label}${d.path ? ` (${shortPath(d.path)})` : ""}${d.text ? `: ${String(d.text).split("\n")[0].slice(0, 100)}` : ""}`);
799
+ else console.log(JSON.stringify(result, null, 2));
800
+ if (stderr) console.error(stderr);
801
+ }
802
+
803
+ // ---------- soul set: runtime defaults and instructions of an editable soul ----------
804
+ /** Rewrites only the given soul.yaml fields, preserving every other line
805
+ * (unknown keys, comments, order), and replaces AGENTS.md when asked.
806
+ * Packaged souls are read-only (their source is the package). */
807
+ async function soulCmd() {
808
+ const bail = (code, msg) => (JSON_MODE ? jsonFail(code, msg) : die(msg));
809
+ dropAmbientRoot();
810
+ if (args[1] !== "set") bail("E_USAGE", "usage: oats soul set <name> [--dir <scope>] [--agents-root <abs>] [--runtime pi|claude|codex] [--model <m> | --no-model] [--yolo | --no-yolo] [--backend tmux|herdr] [--description <d> | --no-description] [--instructions-file <path> | --instructions-stdin] [--json]");
811
+ const name = args[2];
812
+ if (!name || name.startsWith("--")) bail("E_BAD_ARGS", "soul set needs a soul name");
813
+ const ctx = dirFlag();
814
+ const agentsRootFlag = flag("agents-root");
815
+ if (agentsRootFlag === true) bail("E_BAD_ARGS", "--agents-root needs an absolute agents directory");
816
+ let r;
817
+ try { r = resolveOatsConfig(ctx); } catch (e) { bail(e.code || "E_CONFIG_BROKEN", e.message); }
818
+ let soul;
819
+ try { soul = selectSoul(scopeSouls(ctx, r).souls, name, agentsRootFlag, ctx); } catch (e) { bail(e.code || "E_SOUL_UNKNOWN", e.message); }
820
+ if (!soul.editable.fields.length) bail("E_SOUL_READONLY", `${name} is a ${soul.kind} soul: ${soul.editable.reason}`);
821
+ // Field changes, validated before anything is written.
822
+ const changes = {};
823
+ const has = (f) => args.includes(`--${f}`);
824
+ const val = (f) => { const v = flag(f); if (v === true) bail("E_BAD_ARGS", `--${f} needs a value`); return v; };
825
+ if (has("runtime")) { const v = val("runtime"); if (!["pi", "claude", "codex"].includes(v)) bail("E_BAD_ARGS", "--runtime must be pi, claude or codex"); changes.runtime = v; }
826
+ if (has("model") && has("no-model")) bail("E_BAD_ARGS", "choose --model <m> or --no-model, not both");
827
+ if (has("model")) { const v = val("model"); if (!v.trim()) bail("E_BAD_ARGS", "--model needs a model id (use --no-model to clear)"); changes.model = assertSafeConfigValue(v, "--model"); }
828
+ if (has("no-model")) changes.model = null;
829
+ if (has("yolo") && has("no-yolo")) bail("E_BAD_ARGS", "choose --yolo or --no-yolo, not both");
830
+ if (has("yolo")) changes.yolo = true;
831
+ if (has("no-yolo")) changes.yolo = false;
832
+ if (has("backend")) { const v = val("backend"); if (!["tmux", "herdr"].includes(v)) bail("E_BAD_ARGS", "--backend must be tmux or herdr"); changes.backend = v; }
833
+ if (has("description") && has("no-description")) bail("E_BAD_ARGS", "choose --description <d> or --no-description, not both");
834
+ if (has("description")) changes.description = assertSafeConfigValue(val("description"), "--description");
835
+ if (has("no-description")) changes.description = null;
836
+ let instructions;
837
+ if (has("instructions-file") && has("instructions-stdin")) bail("E_BAD_ARGS", "choose --instructions-file or --instructions-stdin, not both");
838
+ if (has("instructions-file")) {
839
+ const file = val("instructions-file");
840
+ let bytes;
841
+ try { bytes = readFileSync(file); } catch (e) { bail("E_BAD_ARGS", `--instructions-file ${file}: ${e.message}`); }
842
+ if (bytes.includes(0)) bail("E_BAD_ARGS", "--instructions-file must be text without NUL bytes");
843
+ if (bytes.length > INSPECT_TEXT_CAP) bail("E_BAD_ARGS", `--instructions-file is ${bytes.length} bytes; the bound is ${INSPECT_TEXT_CAP} (what inspect can answer whole)`);
844
+ instructions = bytes;
845
+ }
846
+ if (has("instructions-stdin")) {
847
+ // The routed form: bytes arrive on stdin (the ssh transport), bounded
848
+ // while reading, exactly like session receive.
849
+ if (process.stdin.isTTY) bail("E_BAD_ARGS", "--instructions-stdin reads the instructions from stdin");
850
+ let bytes;
851
+ try { bytes = await readStreamBounded(process.stdin, INSPECT_TEXT_CAP); } catch (e) { bail(e.code || "E_BAD_ARGS", e.message); }
852
+ if (bytes.includes(0)) bail("E_BAD_ARGS", "instructions must be text without NUL bytes");
853
+ // An empty completed stream is a deliberate replacement with nothing,
854
+ // exactly like an empty --instructions-file: the option itself states
855
+ // the intent, and a TTY was refused above.
856
+ instructions = bytes;
857
+ }
858
+ if (!Object.keys(changes).length && !instructions) bail("E_BAD_ARGS", "nothing to set: pass at least one of --runtime, --model/--no-model, --yolo/--no-yolo, --backend, --description/--no-description, --instructions-file");
859
+ for (const f of Object.keys(changes)) if (!soul.editable.fields.includes(f)) bail("E_BAD_ARGS", `${f} is not an editable field of ${name}`);
860
+ const before = { runtime: soul.runtime, model: soul.model, yolo: soul.yolo, backend: soul.backend, description: soul.description };
861
+ // soul.yaml: replace or append `key: value` lines in place; a cleared
862
+ // field's line is removed; nothing else in the file moves.
863
+ let yamlText = "";
864
+ try { yamlText = readFileSync(soul.soulFile, "utf8"); } catch (e) { bail("E_SOUL_UNKNOWN", `${soul.soulFile}: ${e.message}`); }
865
+ const lines = yamlText.replace(/\n*$/, "").split("\n");
866
+ for (const [key, value] of Object.entries(changes)) {
867
+ const idx = lines.findIndex((l) => new RegExp(`^${key}:\\s`).test(l) || l === `${key}:`);
868
+ if (value === null) { if (idx >= 0) lines.splice(idx, 1); continue; }
869
+ const line = `${key}: ${value}`;
870
+ if (idx >= 0) lines[idx] = line; else lines.push(line);
871
+ }
872
+ const receipt = { soul: name, kind: soul.kind, agentsRoot: soul.agentsRoot, file: soul.soulFile, instructionsFile: soul.instructionsFile, changed: Object.keys(changes), before, instructions: null };
873
+ if (Object.keys(changes).length) writeFileAtomic(soul.soulFile, lines.join("\n") + "\n");
874
+ if (instructions) {
875
+ const prev = (() => { try { return createHash("sha256").update(readFileSync(soul.instructionsFile)).digest("hex"); } catch { return null; } })();
876
+ writeFileAtomic(soul.instructionsFile, instructions);
877
+ receipt.instructions = { before: prev, after: createHash("sha256").update(instructions).digest("hex"), bytes: instructions.length };
878
+ }
879
+ let after;
880
+ try { after = selectSoul(scopeSouls(ctx, r).souls, name, soul.agentsRoot, ctx); } catch { after = soul; }
881
+ receipt.after = { runtime: after.runtime, model: after.model, yolo: after.yolo, backend: after.backend, description: after.description };
882
+ if (JSON_MODE) { jsonOk(receipt); return; }
883
+ console.log(`Updated soul ${name} (${shortPath(soul.soulFile)})${instructions ? ` and its instructions (${shortPath(soul.instructionsFile)})` : ""}: ${Object.keys(changes).map((k) => `${k}=${changes[k] === null ? "(cleared)" : changes[k]}`).join(", ") || "instructions only"}`);
884
+ console.log("Future instances use these defaults; existing homes keep what they were composed with.");
885
+ }
886
+
333
887
  function doctorJson(dir) {
334
888
  const ctx = resolve(dir || process.cwd());
335
889
  const soulName = flag("soul");
@@ -631,19 +1185,43 @@ function readCapabilitiesModel(file) {
631
1185
  // ---------- use / activation ----------
632
1186
  function use() {
633
1187
  const requested = args[1];
634
- if (!requested || requested.startsWith("--")) die("usage: oats use <capability|none> [--global|--type <agent-type>|--soul <name>] [--disable] [--layer <name>] [--settings k=v [k2=v2 ...]] [--dir <dir>]");
1188
+ if (!requested || requested.startsWith("--")) cmdFail("E_USAGE", "usage: oats use <capability|none> [--global|--type <agent-type>|--soul <name>] [--disable|--inherit] [--layer <name>] [--settings k=v [k2=v2 ...]] [--dir <dir>] [--json]");
635
1189
  const dir = dirFlag();
636
1190
  const level = levelOf(dir);
637
1191
  const file = join(dir, "oats-config.yaml");
638
1192
  const layer = flag("layer");
639
- if (layer && !LAYERS.includes(layer)) die(`--layer must be one of: ${LAYERS.join(", ")}`);
1193
+ if (layer && !LAYERS.includes(layer)) cmdFail("E_BAD_ARGS", `--layer must be one of: ${LAYERS.join(", ")}`);
1194
+ const inherit = args.includes("--inherit");
1195
+ if (inherit && args.includes("--disable")) cmdFail("E_BAD_ARGS", "choose --inherit (remove this level's binding) or --disable (explicit exclusion), not both");
640
1196
  let text = existsSync(file) ? readFileSync(file, "utf8") : `name: ${scaffoldConfigName(dir)}\n`;
641
1197
  const caps = readCapabilitiesModel(file);
1198
+ // The receipt names what this level said before and after, and what is
1199
+ // effective afterwards, so a GUI never has to re-read the file to know.
1200
+ const effectiveAfter = (soulName, layerName, capId) => {
1201
+ try {
1202
+ const r = resolveOatsConfig(dir, soulName);
1203
+ if (layerName) return { layer: layerName, id: r.layers[layerName]?.id || null, provenance: r.provenance[layerName] || null, disabled: !!r.layerDisabled?.[layerName] };
1204
+ const c = r.capabilities.find((x) => x.id === capId);
1205
+ return { capability: capId, enabled: !!c, provenance: c?.provenance || [], settings: c?.settings || {} };
1206
+ } catch (e) { return { error: e.message }; }
1207
+ };
1208
+ const answer = (receipt, line) => { if (JSON_MODE) jsonOk(receipt); else console.log(line); };
642
1209
  if (requested === "none") {
643
- if (!layer) die("oats use none requires --layer <name>");
1210
+ if (!layer) cmdFail("E_BAD_ARGS", "oats use none requires --layer <name>");
1211
+ // A layer `none` is a LEVEL statement; there is no per-soul or per-type
1212
+ // none, so a target here must be refused, never silently widened.
1213
+ if (flag("soul") !== undefined || flag("type") !== undefined) cmdFail("E_BAD_ARGS", `oats use none --layer ${layer} disables the layer for this whole level; it takes no --soul or --type (exclude one capability for a soul with oats use <capability> --soul <name> --disable)`);
1214
+ const before = caps.layers[layer] === "none" ? "none" : caps.layers[layer] ? caps.layers[layer].capability : null;
1215
+ if (inherit) {
1216
+ if (caps.layers[layer] !== "none") cmdFail("E_NOT_BOUND", `layer ${layer} is not explicitly none at ${level} level (${shortPath(file)}); nothing to inherit from`);
1217
+ delete caps.layers[layer];
1218
+ writeFileSync(file, replaceCapabilitiesBlock(text, caps));
1219
+ answer({ capability: null, action: "inherit", target: "layer", layer, level, file, before: { layer: before }, after: { layer: null, effective: effectiveAfter(undefined, layer) } }, `Layer ${layer} at ${level} level now inherits (${shortPath(file)})`);
1220
+ return;
1221
+ }
644
1222
  caps.layers[layer] = "none";
645
1223
  writeFileSync(file, replaceCapabilitiesBlock(text, caps));
646
- console.log(`Disabled fundamental layer ${layer} at ${level} level (${shortPath(file)})`);
1224
+ answer({ capability: null, action: "layer-none", target: "layer", layer, level, file, before: { layer: before }, after: { layer: "none", effective: effectiveAfter(undefined, layer) } }, `Disabled fundamental layer ${layer} at ${level} level (${shortPath(file)})`);
647
1225
  return;
648
1226
  }
649
1227
  const manifest = capabilityManifest(requested, dir);
@@ -664,28 +1242,57 @@ function use() {
664
1242
  cmdFail("E_NO_CONFIG", `capability "${requested}" is present in the capability store at ${shellQuote(dir)}, but there is no oats-config.yaml at this scope or any level above it — \`oats use\` activates into a config file and this scope has none. Create the minimal one with \`oats init --raw --dir ${shellQuote(dir)}\`, then re-run \`oats use ${requested}\`.`);
665
1243
  return;
666
1244
  }
667
- die(`unknown capability "${requested}" (acquired: ${Object.keys(capabilityManifests(dir)).join(", ") || "none"}) — acquire it with \`oats install ${requested}\` (marketplace: ${Object.keys(marketplaceCapabilities()).join(", ")})`);
1245
+ cmdFail("E_UNKNOWN_CAPABILITY", `unknown capability "${requested}" (acquired: ${Object.keys(capabilityManifests(dir)).join(", ") || "none"}) — acquire it with \`oats install ${requested}\` (marketplace: ${Object.keys(marketplaceCapabilities()).join(", ")})`);
668
1246
  }
669
- if (layer && manifest.layer !== layer) die(`capability "${manifest.capability}" declares layer "${manifest.layer || "none"}", not "${layer}"`);
1247
+ if (layer && manifest.layer !== layer) cmdFail("E_LAYER_MISMATCH", `capability "${manifest.capability}" declares layer "${manifest.layer || "none"}", not "${layer}"`);
670
1248
  const targets = [["agent-types", flag("type")], ["souls", flag("soul")]].filter(([, value]) => value);
671
1249
  if (args.includes("--global")) targets.push(["global", undefined]);
672
- if (targets.length > 1) die("choose exactly one of --global, --type, or --soul");
1250
+ if (targets.length > 1) cmdFail("E_BAD_ARGS", "choose exactly one of --global, --type, or --soul");
673
1251
  const [targetKind, targetName] = targets[0] || ["global", undefined];
1252
+ const targetLabel = targetKind === "global" ? "global" : `${targetKind === "agent-types" ? "type" : "soul"}:${targetName}`;
1253
+ const soulForEffective = targetKind === "souls" ? targetName : undefined;
674
1254
  const enabled = !args.includes("--disable");
1255
+ const bindingOf = (e) => (!e ? undefined : targetKind === "global" ? e.global : e[targetKind]?.[targetName]);
1256
+ const stateOf = (e) => ({ bound: bindingOf(e) !== undefined, enabled: bindingOf(e) === undefined ? null : (typeof bindingOf(e) === "object" ? bindingOf(e).enabled !== false : !!bindingOf(e)), settings: e?.settings && typeof e.settings === "object" ? { ...e.settings } : {} });
1257
+ // --inherit removes THIS level's binding for the addressed target (and the
1258
+ // whole entry once no target is left), so outer scopes and targets apply
1259
+ // again. Distinct from --disable, which writes an explicit exclusion.
1260
+ if (inherit) {
1261
+ const existing = manifest.layer ? caps.layers[manifest.layer] : caps.additive[manifest.capability];
1262
+ const entry0 = existing && existing !== "none" && (!manifest.layer || existing.capability === manifest.capability) ? existing : undefined;
1263
+ const before = stateOf(entry0);
1264
+ if (!entry0 || !before.bound) cmdFail("E_NOT_BOUND", `${manifest.capability} has no ${targetLabel} binding at ${level} level (${shortPath(file)}); nothing to inherit from`);
1265
+ // Only the addressed target goes. Every other binding the entry carries
1266
+ // (an explicit global, other souls, types) is policy this command cannot
1267
+ // tell from intent, so it stays; the receipt names what still applies.
1268
+ if (targetKind === "global") delete entry0.global;
1269
+ else { delete entry0[targetKind][targetName]; if (!Object.keys(entry0[targetKind]).length) delete entry0[targetKind]; }
1270
+ const remaining = [...(entry0.global !== undefined ? [`global: ${entry0.global}`] : []), ...Object.entries(entry0["agent-types"] || {}).map(([t, v]) => `type:${t}: ${JSON.stringify(v)}`), ...Object.entries(entry0.souls || {}).map(([n, v]) => `soul:${n}: ${JSON.stringify(v)}`)];
1271
+ const targetsLeft = remaining.length > 0;
1272
+ if (!targetsLeft) { if (manifest.layer) delete caps.layers[manifest.layer]; else delete caps.additive[manifest.capability]; }
1273
+ writeFileSync(file, replaceCapabilitiesBlock(text, caps));
1274
+ const note = targetsLeft ? `this level still binds ${manifest.capability}: ${remaining.join(", ")}; remove them with oats use ${manifest.capability} --inherit --global|--type <t>|--soul <s> if the intent is full inheritance` : null;
1275
+ answer({ capability: manifest.capability, action: "inherit", target: targetLabel, layer: manifest.layer || null, level, file, entryRemoved: !targetsLeft, remaining, note, before, after: { bound: false, enabled: null, settings: targetsLeft ? before.settings : {}, effective: effectiveAfter(soulForEffective, manifest.layer, manifest.capability) } },
1276
+ `${manifest.capability} ${targetLabel} binding removed at ${level} level${targetsLeft ? `; still bound here: ${remaining.join(", ")}` : " (entry removed)"} (${shortPath(file)})`);
1277
+ return;
1278
+ }
675
1279
  // Locate or create the entry in the right subtree.
676
1280
  let entry;
677
1281
  if (manifest.layer) {
678
1282
  const existing = caps.layers[manifest.layer];
679
1283
  var entryExisted = !!(existing && existing !== "none" && existing.capability === manifest.capability);
680
1284
  entry = entryExisted ? existing : { capability: manifest.capability };
681
- if (existing && existing !== "none" && existing.capability !== manifest.capability && enabled) {
682
- die(`fundamental layer ${manifest.layer} already binds ${existing.capability} at this level — disable it first`);
1285
+ // One entry per layer per level: another capability's entry is never
1286
+ // overwritten, not even by an exclusion, and the remedy is exact.
1287
+ if (existing && existing !== "none" && existing.capability !== manifest.capability) {
1288
+ cmdFail("E_LAYER_BOUND", `fundamental layer ${manifest.layer} already binds ${existing.capability} at ${level} level (${shortPath(file)}); remove that binding first with oats use ${existing.capability} --inherit --global (and --type/--soul for each of its targets), or disable the layer here with oats use none --layer ${manifest.layer}, then use ${manifest.capability}`);
683
1289
  }
684
1290
  caps.layers[manifest.layer] = entry;
685
1291
  } else {
686
1292
  entry = caps.additive[manifest.capability] || {};
687
1293
  caps.additive[manifest.capability] = entry;
688
1294
  }
1295
+ const beforeState = stateOf(entryExisted || !manifest.layer ? entry : undefined);
689
1296
  const from = originToFrom(manifest._origin);
690
1297
  if (from && !entry.from) entry.from = from;
691
1298
  const settingsArgs = [];
@@ -693,14 +1300,14 @@ function use() {
693
1300
  if (args[i] !== "--settings") continue;
694
1301
  let consumed = 0;
695
1302
  for (let j = i + 1; j < args.length && !args[j].startsWith("--"); j++, consumed++) settingsArgs.push(args[j]);
696
- if (!consumed) die("--settings expects one or more key=value pairs");
1303
+ if (!consumed) cmdFail("E_BAD_ARGS", "--settings expects one or more key=value pairs");
697
1304
  i += consumed;
698
1305
  }
699
1306
  if (settingsArgs.length) {
700
1307
  entry.settings = entry.settings && typeof entry.settings === "object" ? entry.settings : {};
701
1308
  for (const kv of settingsArgs) {
702
1309
  const eq = kv.indexOf("=");
703
- if (eq <= 0) die(`--settings expects key=value, got "${kv}"`);
1310
+ if (eq <= 0) cmdFail("E_BAD_ARGS", `--settings expects key=value, got "${kv}"`);
704
1311
  // WRITE side of the refusals the readers enforce. Two distinct hazards on
705
1312
  // this one line:
706
1313
  // - `--settings __proto__=x` assigned through the inherited setter,
@@ -730,8 +1337,12 @@ function use() {
730
1337
  entry[targetKind][assertSafeConfigWriteKey(targetName, `--${targetKind === "agent-types" ? "type" : "soul"} name ${JSON.stringify(String(targetName))}`)] = enabled;
731
1338
  }
732
1339
  writeFileSync(file, replaceCapabilitiesBlock(text, caps));
733
- console.log(`${enabled ? "Activated" : "Excluded"} ${manifest.capability} for ${targetKind === "global" ? "global" : `${targetKind === "agent-types" ? "type" : "soul"} ${targetName}`} at ${level} level (${shortPath(file)})`);
734
- for (const miss of capabilityMissingRequires(manifest.capability, dir)) console.log(`WARNING: required command "${miss.command}" not on PATH — ${miss.why || ""}${miss.install ? ` (install: ${miss.install})` : ""}`);
1340
+ const afterState = stateOf(entry);
1341
+ const missing = capabilityMissingRequires(manifest.capability, dir);
1342
+ answer({ capability: manifest.capability, action: enabled ? "enable" : "disable", target: targetLabel, layer: manifest.layer || null, level, file, settings: entry.settings || {}, before: beforeState, after: { ...afterState, effective: effectiveAfter(soulForEffective, manifest.layer, manifest.capability) }, missingRequires: missing },
1343
+ `${enabled ? "Activated" : "Excluded"} ${manifest.capability} for ${targetKind === "global" ? "global" : `${targetKind === "agent-types" ? "type" : "soul"} ${targetName}`} at ${level} level (${shortPath(file)})`);
1344
+ if (JSON_MODE) return;
1345
+ for (const miss of missing) console.log(`WARNING: required command "${miss.command}" not on PATH — ${miss.why || ""}${miss.install ? ` (install: ${miss.install})` : ""}`);
735
1346
  console.log("New instances receive the resolved capability; committed souls are unchanged.");
736
1347
  }
737
1348
 
@@ -2881,7 +3492,7 @@ function retireCmd() {
2881
3492
  console.error(`Fix the cause and re-run \`oats retire ${r.retired}\`; the home holds the state that cleanup needs.`);
2882
3493
  process.exit(1);
2883
3494
  }
2884
- console.log(`Retired ${r.retired} (agent ${r.agent})${r.worktreeRemoved ? ", worktree removed" : ""}${r.branchDeleted ? ", branch deleted" : ""}${r.harvested?.length ? `, harvested: ${r.harvested.join(", ")}` : ""}`);
3495
+ console.log(`Retired ${r.retired} (agent ${r.agent})${r.worktreeRemoved ? ", worktree removed" : ""}${r.branchDeleted ? ", branch deleted" : ""}`);
2885
3496
  // Preserving work and not saying so leaves the operator believing it is gone,
2886
3497
  // which is most of the harm of deleting it. Name the classes and the path.
2887
3498
  for (const recovery of r.workRecoveries || (r.workRecovery ? [r.workRecovery] : [])) {
@@ -2960,7 +3571,19 @@ async function sessionCmd() {
2960
3571
  if (file === true) throw Object.assign(new Error("--text-file needs a path"), { code: "E_BAD_ARGS" });
2961
3572
  if (!file && process.stdin.isTTY) throw Object.assign(new Error("provide --text-file or pipe input on stdin"), { code: "E_BAD_ARGS" });
2962
3573
  result = inputInstanceSession(home, readFileSync(file || 0, "utf8"));
2963
- } else throw Object.assign(new Error("usage: oats session inspect|input|attach|start --home /absolute/home [--text-file path] [--model id] [--json]"), { code: "E_BAD_ARGS" });
3574
+ } else if (args[1] === "receive") {
3575
+ // Bytes arrive on stdin (the routed upload pipes them through ssh),
3576
+ // collected event-driven and bounded before anything is written.
3577
+ const name = flag("name");
3578
+ if (!name || name === true) throw Object.assign(new Error("session receive needs --name <file name>"), { code: "E_BAD_ARGS" });
3579
+ if (!home || home === true) throw Object.assign(new Error("session receive needs --home </absolute/instance>"), { code: "E_BAD_ARGS" });
3580
+ if (process.stdin.isTTY) throw Object.assign(new Error("session receive reads the attachment bytes from stdin"), { code: "E_BAD_ARGS" });
3581
+ result = receiveAttachment(home, name, await readStreamBounded(process.stdin, MAX_ATTACHMENT_BYTES));
3582
+ } else if (args[1] === "upload") {
3583
+ const file = flag("file");
3584
+ if (!file || file === true) throw Object.assign(new Error("session upload needs --file <local path>"), { code: "E_BAD_ARGS" });
3585
+ result = uploadAttachment({ file, home: home === true ? undefined : home });
3586
+ } else throw Object.assign(new Error("usage: oats session inspect|input|attach|start|receive|upload --home /absolute/home [--text-file path] [--model id] [--name file] [--file path] [--json]"), { code: "E_BAD_ARGS" });
2964
3587
  if (JSON_MODE) jsonOk(result); else console.log(JSON.stringify(result, null, 2));
2965
3588
  } catch (e) { cmdFail(e.code || "E_SESSION_FAILED", e.message); }
2966
3589
  }
@@ -3255,7 +3878,7 @@ function versionCmd() {
3255
3878
  // on it (an older CLI without the surface must fail closed with a
3256
3879
  // reason, not an argument error). `features`: kernel abilities a peer
3257
3880
  // must see before relying on them (retire-home: retire --home).
3258
- console.log(JSON.stringify({ schemaVersion: 1, name: "@awebai/oats", version: OATS_VERSION, desktopApi: 1, runtimes: ["pi", "claude", "codex"], sessionBackends: ["tmux", "herdr"], launchOptions: ["yolo"], remote: ["spawn", "retire", "status", "session", "session-start", "roster", "harvest", "schedule"], features: ["retire-home", "session-start", "schedule"], scheduleApi: SCHEDULE_API }));
3881
+ console.log(JSON.stringify({ schemaVersion: 1, name: "@awebai/oats", version: OATS_VERSION, desktopApi: 1, runtimes: ["pi", "claude", "codex"], sessionBackends: ["tmux", "herdr"], launchOptions: ["yolo"], remote: ["spawn", "retire", "status", "session", "session-start", "roster", "harvest", "schedule", "session-upload", "operations"], features: ["retire-home", "session-start", "schedule", "session-upload", "operations"], scheduleApi: SCHEDULE_API, operationsApi: 1 }));
3259
3882
  return;
3260
3883
  }
3261
3884
  console.log(`@awebai/oats ${OATS_VERSION} (desktop API v1)`);
@@ -3390,7 +4013,11 @@ function serverRouteCmd() {
3390
4013
  const bail = (code, msg) => (JSON_MODE ? jsonFail(code, msg) : die(msg));
3391
4014
  const id = flag("server");
3392
4015
  if (id === true || !id) bail("E_BAD_ARGS", "--server needs a registered server id (oats server list)");
3393
- if (flag("dir") !== undefined || args.some((a) => a.startsWith("--dir="))) bail("E_BAD_ARGS", "--dir cannot be combined with --server: the remote workspace comes from the server registration");
4016
+ // The operations contract addresses an exact member context on the host,
4017
+ // so its explicit --dir travels; every other routed command takes its
4018
+ // scope from the registration.
4019
+ const explicitScopeOk = ["inspect", "operation", "use", "soul"].includes(cmd);
4020
+ if (!explicitScopeOk && (flag("dir") !== undefined || args.some((a) => a.startsWith("--dir=")))) bail("E_BAD_ARGS", "--dir cannot be combined with --server: the remote workspace comes from the server registration");
3394
4021
  // Interactive viewer: `oats session attach --server <id> --instance <name>`
3395
4022
  // (or --home </abs/remote/home>) runs the execution host's own attach
3396
4023
  // through an ssh PTY with this terminal's stdio; nothing is captured.
@@ -3462,7 +4089,19 @@ function serverRouteCmd() {
3462
4089
  console.log(`Started ${r.instance || r.home} on ${id} (${r.backend}${r.model ? `, model ${r.model}` : ""}, ${r.reused === "pane" ? "in its existing pane" : r.reused === "adopted" ? "adopted the pending session" : "new window"})`);
3463
4090
  return;
3464
4091
  }
3465
- if (args[1] !== "attach") bail("E_USAGE", "--server routes `session inspect`, `session start` and `session attach`; input runs on the execution host (the wake broker calls it there)");
4092
+ if (args[1] === "upload") {
4093
+ // Bytes stream to `session receive` on the execution host over the
4094
+ // same route as attach; the answer's checksum is verified here.
4095
+ const file = flag("file");
4096
+ if (!file || file === true) bail("E_BAD_ARGS", "session upload needs --file <local path>");
4097
+ let r;
4098
+ try { r = uploadAttachment({ file, server: id, ...addr }); } catch (e) { bail(e.code || "E_UPLOAD_FAILED", e.message); }
4099
+ if (r.stderr) process.stderr.write(r.stderr + "\n");
4100
+ if (JSON_MODE) { jsonOk(r); return; }
4101
+ console.log(`Uploaded ${r.name} (${r.bytes} bytes) to ${r.instance || r.home} on ${id}: ${r.path}`);
4102
+ return;
4103
+ }
4104
+ if (args[1] !== "attach") bail("E_USAGE", "--server routes `session inspect`, `session start`, `session upload` and `session attach`; input runs on the execution host (the wake broker calls it there)");
3466
4105
  let route;
3467
4106
  try { route = attachArgv(id, addr, { skipVersionCheck: args.includes("--print") }); }
3468
4107
  catch (e) { bail(e.code || "E_BAD_ARGS", e.message); }
@@ -3474,6 +4113,7 @@ function serverRouteCmd() {
3474
4113
  // local --task-file is read here and travels as --task text, since the
3475
4114
  // remote cannot read this machine's files.
3476
4115
  const rest = [];
4116
+ let routedInput;
3477
4117
  for (let i = 1; i < args.length; i++) {
3478
4118
  const a = args[i];
3479
4119
  if (a === "--server") { i++; continue; }
@@ -3503,10 +4143,23 @@ function serverRouteCmd() {
3503
4143
  rest.push("--wake-message", readFileSync(f, "utf8"));
3504
4144
  continue;
3505
4145
  }
4146
+ // Soul instructions travel as BYTES on the ssh stdin (the same transport
4147
+ // as session upload), never as a path the host cannot read nor as a
4148
+ // command-line argument.
4149
+ if (a === "--instructions-file") {
4150
+ const f = args[++i];
4151
+ if (!f || f.startsWith("--")) bail("E_BAD_ARGS", "--instructions-file needs a path");
4152
+ let bytes; try { bytes = readFileSync(f); } catch (e) { bail("E_BAD_ARGS", `instructions file not readable: ${f}: ${e.message}`); }
4153
+ if (bytes.includes(0)) bail("E_BAD_ARGS", "--instructions-file must be text without NUL bytes");
4154
+ if (bytes.length > INSPECT_TEXT_CAP) bail("E_BAD_ARGS", `--instructions-file is ${bytes.length} bytes; the bound is ${INSPECT_TEXT_CAP}`);
4155
+ routedInput = bytes;
4156
+ rest.push("--instructions-stdin");
4157
+ continue;
4158
+ }
3506
4159
  rest.push(a);
3507
4160
  }
3508
4161
  let routed;
3509
- try { routed = routeCommand(id, cmd, rest); }
4162
+ try { routed = routeCommand(id, cmd, rest, routedInput === undefined ? {} : { input: routedInput }); }
3510
4163
  catch (e) { bail(e.code || "E_SSH", e.message); }
3511
4164
  const { envelope, stderr } = routed;
3512
4165
  if (stderr && stderr.trim()) process.stderr.write(stderr.endsWith("\n") ? stderr : stderr + "\n");
@@ -3564,11 +4217,14 @@ try {
3564
4217
  // and exits 0 BEFORE any dispatch: a fresh operator inspects --help before
3565
4218
  // using a command, and `install --help` once ran the bare restore while
3566
4219
  // `okf harvest --help` spawned a harvester (BeadHub, 2026-09-05).
3567
- const KERNEL_COMMANDS = new Set(["capture", "config", "create", "doctor", "experimental", "init", "inject", "install", "list", "migrate", "pane", "recall", "remove", "retire", "root", "schedule", "server", "session", "setup", "spawn", "status", "trust", "type", "update", "use", "version"]);
4220
+ const KERNEL_COMMANDS = new Set(["capture", "config", "create", "doctor", "inspect", "operation", "soul", "experimental", "init", "inject", "install", "list", "migrate", "pane", "recall", "remove", "retire", "root", "schedule", "server", "session", "setup", "spawn", "status", "trust", "type", "update", "use", "version"]);
3568
4221
  const wantsHelp = args.slice(1).some((a) => a === "--help" || a === "-h");
3569
4222
  if (cmd && KERNEL_COMMANDS.has(cmd) && wantsHelp) { if (JSON_MODE) { jsonOk({ command: cmd, usage: usageLinesFor(cmd) }); process.exit(0); } usageFor(cmd); process.exit(0); }
3570
- if (flag("server") !== undefined && ["spawn", "retire", "status", "session", "okf", "schedule"].includes(cmd)) serverRouteCmd();
4223
+ if (flag("server") !== undefined && ["spawn", "retire", "status", "session", "okf", "schedule", "inspect", "operation", "use", "soul"].includes(cmd)) serverRouteCmd();
3571
4224
  else if (cmd === "server") serverCmd();
4225
+ else if (cmd === "inspect") inspectCmd();
4226
+ else if (cmd === "operation") operationCmd();
4227
+ else if (cmd === "soul") await soulCmd();
3572
4228
  else if (cmd === "doctor") {
3573
4229
  const doctorDir = args[1] && !args[1].startsWith("--") ? args[1] : undefined;
3574
4230
  args.includes("--json") ? doctorJson(doctorDir) : doctor(doctorDir);
@@ -3664,6 +4320,15 @@ Usage:
3664
4320
  oats session start --server <id> start a stopped remote instance in its existing home
3665
4321
  --instance <name> | --home <abs> over its saved route; the server must advertise
3666
4322
  [--model <m>] [--json] session-start (oats 0.22.9 or later)
4323
+ oats inspect|operation|use|soul --server <id> the same commands on a registered server over its
4324
+ ... [--dir <remote member>] [--home <abs>] saved route (an explicit --dir travels as is; a --home
4325
+ is its own context; else the registered workspace);
4326
+ soul set --instructions-file streams the bytes; the
4327
+ server must advertise operations (oats 0.22.16 or later)
4328
+ oats session upload --server <id> copy a local file into a remote instance's private
4329
+ --instance <name> | --home <abs> attachments over its saved route (bytes stream on
4330
+ --file <path> [--json] ssh stdin; sha256 verified); the server must
4331
+ advertise session-upload (oats 0.22.13 or later)
3667
4332
  oats create <name> [--local] create an agent soul; --local = full
3668
4333
  [--description <d>] [--repo <r>] soul under local-agents/ (uncommitted,
3669
4334
  [--work <mode>] [--runtime pi|claude|codex] gitignored; same memory + lifecycle)
@@ -3676,6 +4341,11 @@ Usage:
3676
4341
  routes to that host's workspace
3677
4342
  oats spawn ... --wake-file <json> | --wake-every <N> --wake-message <text> save a wake schedule
3678
4343
  bound to the new instance's home (docs/schedules.md)
4344
+ oats session upload --file <path> store a copy of a local file as a private attachment
4345
+ --home <absolute-home> [--json] in the instance home (.oats-attachments/); answers
4346
+ {path, bytes, sha256}; never types into the session
4347
+ oats session receive --home <abs> --name store attachment bytes read from stdin (the routed
4348
+ <file> [--json] upload's remote half)
3679
4349
  oats session start --home <absolute-home> start a STOPPED instance again in its existing home
3680
4350
  [--model <m>] [--json] (same identity, worktree, notes and launch env; no
3681
4351
  spawn hooks); --model replaces the recorded model
@@ -3695,6 +4365,25 @@ Usage:
3695
4365
  [--self] [--delete-branch] worktree, home); --self = retire the
3696
4366
  [--keep-dir] [--json] CALLING instance: the window dies, then
3697
4367
  a detached external retirement runs
4368
+ oats inspect [--dir <scope>] [--soul <name> one authoritative JSON answer for a GUI: souls
4369
+ [--agents-root <abs>]] [--home <abs>] (runtime defaults, editability, instructions),
4370
+ [--json] installed capabilities with health, effective
4371
+ layer bindings and activation, declared
4372
+ operations with availability; --home answers the
4373
+ running home's captured bindings and their drift
4374
+ from the current config
4375
+ oats operation run <layer>:<name> run an operation the effective provider of that
4376
+ (--home <abs> | --soul <name> [--dir <d>]) layer declares (knowledge:harvest, knowledge:
4377
+ [--arg k=v ...] [--json] inspect ...): resolved from the home's captured
4378
+ bindings or the scope's config, trust checked, the provider's
4379
+ own command run in the home or scope, envelope
4380
+ relayed; a view answers {documents: [...]}
4381
+ oats soul set <name> [--dir <scope>] change an editable soul's launch defaults and/or
4382
+ [--agents-root <abs>] [--runtime r] instructions in place (soul.yaml lines replaced,
4383
+ [--model m | --no-model] everything else kept; AGENTS.md replaced from
4384
+ [--yolo | --no-yolo] [--backend b] --instructions-file); packaged souls are refused;
4385
+ [--description d | --no-description] the receipt carries before/after and sha256s
4386
+ [--instructions-file <path>] [--json]
3698
4387
  oats doctor [dir] [--soul <name>] [--json] resolved targets, trust, requirements;
3699
4388
  --soul shows final composed AGENTS.md
3700
4389
  oats update [--check] [--yes] check npm for a newer kernel+pi bridge and