@awebai/oats 0.22.10 → 0.22.14

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
@@ -39,8 +39,11 @@ import {
39
39
  assertNoSymlinkedParents, copyFileAtomic, writeFileAtomic,
40
40
  runRequirementInstall, selectConfigTemplate, validateConfigTemplate, writeAdoptedTemplate,
41
41
  } from "../lib/packages.mjs";
42
- import { attachArgv, checkRemote, forgetSnapshot, getServer, inspectRemote, startRemote, listSnapshots, readServers, rosterGroups, routeCommand, targetOf, validateServer, writeServers, SERVERS_FILE } from "../lib/servers.mjs";
42
+ import { attachArgv, checkRemote, forgetSnapshot, getServer, inspectRemote, startRemote, scheduleRemote, listSnapshots, readServers, rosterGroups, routeCommand, targetOf, validateServer, writeServers, SERVERS_FILE } from "../lib/servers.mjs";
43
43
  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 { hostUnitStatus, installHostUnit, uninstallHostUnit } from "../lib/schedule-host.mjs";
46
+ import { receiveAttachment, uploadAttachment, readStreamBounded, MAX_ATTACHMENT_BYTES } from "../lib/attachments.mjs";
44
47
 
45
48
  const args = process.argv.slice(2);
46
49
  const cmd = args[0];
@@ -2754,6 +2757,34 @@ function spawnCmd() {
2754
2757
  const relativeRoot = flag("relative-root");
2755
2758
  if (relativeRoot !== undefined && (relativeRoot === true || !String(relativeRoot).trim())) bail("E_BAD_ARGS", "--relative-root needs an agents-root path");
2756
2759
  if (relativeRoot && !relativeTo) bail("E_BAD_ARGS", "--relative-root only qualifies --relative-to/--parent");
2760
+ // Wake at launch: --wake-file <private JSON {cron, tz, message, enabled}>
2761
+ // is the backend bridge; --wake-every/--wake-cron/--wake-tz/--wake-message
2762
+ // (or --wake-message-file) are the human sugar for the same object. The
2763
+ // wake job is saved AFTER a successful spawn, bound to the returned home.
2764
+ let wake;
2765
+ try {
2766
+ const wakeFile = flag("wake-file");
2767
+ if (wakeFile === true) bail("E_BAD_ARGS", "--wake-file needs a path");
2768
+ const wakeJson = flag("wake-json");
2769
+ if (wakeJson === true) bail("E_BAD_ARGS", "--wake-json needs the wake object as JSON text");
2770
+ if (wakeJson) {
2771
+ let doc; try { doc = JSON.parse(wakeJson); } catch (e) { bail("E_SCHEDULE_INVALID", `--wake-json is not valid JSON: ${e.message}`); }
2772
+ if (!doc || typeof doc !== "object") bail("E_SCHEDULE_INVALID", "--wake-json must hold {cron, tz, message, enabled}");
2773
+ wake = { cron: doc.cron, tz: doc.tz, message: doc.message, enabled: doc.enabled === undefined ? true : doc.enabled };
2774
+ } else if (wakeFile) {
2775
+ if (!existsSync(wakeFile)) bail("E_BAD_ARGS", `--wake-file not found: ${wakeFile}`);
2776
+ let doc; try { doc = JSON.parse(readFileSync(wakeFile, "utf8")); } catch (e) { bail("E_SCHEDULE_INVALID", `--wake-file is not valid JSON: ${e.message}`); }
2777
+ if (!doc || typeof doc !== "object") bail("E_SCHEDULE_INVALID", "--wake-file must hold {cron, tz, message, enabled}");
2778
+ wake = { cron: doc.cron, tz: doc.tz, message: doc.message, enabled: doc.enabled === undefined ? true : doc.enabled };
2779
+ } else if (flag("wake-every") !== undefined || flag("wake-cron") !== undefined) {
2780
+ const mf = flag("wake-message-file");
2781
+ if (mf === true) bail("E_BAD_ARGS", "--wake-message-file needs a path");
2782
+ if (mf && !existsSync(mf)) bail("E_BAD_ARGS", `--wake-message-file not found: ${mf}`);
2783
+ const message = mf ? readFileSync(mf, "utf8") : flag("wake-message") === true ? undefined : flag("wake-message");
2784
+ wake = wakeFromFlags({ every: flag("wake-every") === true ? undefined : flag("wake-every"), cron: flag("wake-cron") === true ? undefined : flag("wake-cron"), tz: flag("wake-tz") === true ? undefined : flag("wake-tz"), message });
2785
+ }
2786
+ if (wake && (typeof wake.message !== "string" || !wake.message.trim())) bail("E_SCHEDULE_INVALID", "wake message: non-empty text is required");
2787
+ } catch (e) { if (e?.code?.startsWith?.("E_")) bail(e.code, e.message); throw e; }
2757
2788
  let r;
2758
2789
  try {
2759
2790
  r = spawnInstance(root, agent, {
@@ -2770,11 +2801,19 @@ function spawnCmd() {
2770
2801
  if (TYPED_CLI_FAILURES.has(e?.code)) throw e;
2771
2802
  bail(e.code === "E_RELATIVE_AMBIGUOUS" ? "E_RELATIVE_AMBIGUOUS" : "E_SPAWN_FAILED", e.message || e); throw e;
2772
2803
  }
2804
+ // The instance exists from here on: a failed wake save is reported beside
2805
+ // the full receipt, never hidden, and never causes a second spawn.
2806
+ let wakeSchedule, wakeScheduleError;
2807
+ if (wake) {
2808
+ try { wakeSchedule = saveWakeForHome(scheduleScopeOf(workspaceOf(root)), { instance: r.instance, home: r.home, wake }); }
2809
+ catch (e) { wakeScheduleError = { code: e.code || "E_SCHEDULE_FAILED", message: e.message }; r.warnings = [...(r.warnings || []), `wake schedule NOT saved: ${e.message}`]; }
2810
+ }
2773
2811
  if (JSON_MODE) {
2774
2812
  // Desktop CLI API v1 spawn result — a FIXED shape (see docs/desktop-cli-api.md).
2775
2813
  jsonOk({
2776
2814
  instance: r.instance, agent: r.agent, home: r.home, work: r.work,
2777
2815
  branch: r.branch || null, launched: r.launched, warnings: r.warnings || [],
2816
+ ...(wakeSchedule ? { wakeSchedule } : {}), ...(wakeScheduleError ? { wakeScheduleError } : {}),
2778
2817
  tmux: r.tmux || null, repo: r.repo || null, runtime: r.runtime || null,
2779
2818
  model: r.model || null, parent: r.parentInstance || null,
2780
2819
  sibling: r.siblingInstance || null, relation: r.relation || null,
@@ -2786,6 +2825,8 @@ function spawnCmd() {
2786
2825
  }
2787
2826
  console.log(`Spawned ${r.instance} (${r.work}${r.branch ? `, branch ${r.branch}` : ""})${r.launched ? r.sessionTarget ? ` — Herdr pane "${r.sessionTarget.paneId}"` : ` — tmux window "${r.tmux.window}"` : " — not launched"}`);
2788
2827
  console.log(` home: ${shortPath(r.home)}`);
2828
+ if (wakeSchedule) console.log(` wake: schedule ${wakeSchedule.id} (${wakeSchedule.cron} ${wakeSchedule.tz}), next ${wakeSchedule.nextRun || "disabled"}`);
2829
+ if (wakeScheduleError) console.error(` wake: NOT saved — ${wakeScheduleError.message} (the instance is created and launched; add the wake by hand with oats schedule add)`);
2789
2830
  if (!r.launched) console.log(` launch: (cd ${shortPath(r.home)} && ${r.command})`);
2790
2831
  for (const w of r.warnings || []) console.log(` WARNING: ${w}`);
2791
2832
  console.log(` attach: ${r.attach}`);
@@ -2809,7 +2850,11 @@ function retireCmd() {
2809
2850
  // Stdout carries only the envelope in JSON mode (the Desktop parses it).
2810
2851
  if (hit && resolve(hit.root) !== resolve(root)) { root = hit.root; (args.includes("--json") ? console.error : console.log)(`(cross-repo: instance homes at ${shortPath(root)})`); }
2811
2852
  }
2853
+ const retiringHome = homeFlag || findInstanceHome(root, name);
2812
2854
  const r = retireInstance(root, name, { home: homeFlag, self: isSelf, deleteBranch: args.includes("--delete-branch"), keepDir: args.includes("--keep-dir"), force: args.includes("--force") });
2855
+ // A retired home's wake jobs are forgotten (definitions only; nothing is
2856
+ // stopped by this); a deferred self-retire keeps them until the home is gone.
2857
+ if (retiringHome && r.removedDir !== false && !r.deferred) { try { const gone = removeWakeForHome(scheduleScopeOf(workspaceOf(root)), retiringHome); if (gone.length) r.wakeSchedulesRemoved = gone; } catch (e) { r.warnings = [...(r.warnings || []), `wake schedules not cleaned: ${e.message}`]; } }
2813
2858
  // Deferred self-retire: nothing has been inspected, run, or removed yet. The
2814
2859
  // caller's window dies first; a detached process then retires the instance
2815
2860
  // as an external operator and writes its outcome beside the home.
@@ -2847,6 +2892,56 @@ function retireCmd() {
2847
2892
  if (isSelf) console.log("This window dies in ~8s — say any goodbyes now.");
2848
2893
  }
2849
2894
 
2895
+ /** `oats schedule ...`: workspace-scoped definitions, host-owned execution
2896
+ * (lib/schedule.mjs). Every subcommand answers the envelope; nothing here
2897
+ * launches unless a job is due or run-now is asked. */
2898
+ function scheduleCmd() {
2899
+ const sub = args[1];
2900
+ const id = args[2] && !args[2].startsWith("--") ? args[2] : undefined;
2901
+ // One schedule-owning scope for a directory: the team workspace (the
2902
+ // config level declaring the team), else the outermost config level.
2903
+ const ws = scheduleScopeOf(dirFlag());
2904
+ const io = { hostStatus: () => hostUnitStatus() };
2905
+ const out = (result) => { if (JSON_MODE) jsonOk(result); else console.log(JSON.stringify(result, null, 2)); };
2906
+ const readSpec = () => {
2907
+ const inline = flag("spec-json");
2908
+ if (inline && inline !== true) { try { return JSON.parse(inline); } catch (e) { throw scheduleError("E_SCHEDULE_INVALID", `--spec-json is not valid JSON: ${e.message}`, { field: "file" }); } }
2909
+ const f = flag("file");
2910
+ if (!f || f === true) throw scheduleError("E_BAD_ARGS", "--file <spec.json> is required (a private JSON file with the definition)");
2911
+ if (!existsSync(f)) throw scheduleError("E_BAD_ARGS", `spec file not found: ${f}`);
2912
+ try { return JSON.parse(readFileSync(f, "utf8")); } catch (e) { throw scheduleError("E_SCHEDULE_INVALID", `${f} is not valid JSON: ${e.message}`, { field: "file" }); }
2913
+ };
2914
+ const needId = () => { if (!id) throw scheduleError("E_BAD_ARGS", `oats schedule ${sub} <id>`); return id; };
2915
+ try {
2916
+ switch (sub) {
2917
+ case "list": return out(listSchedules(ws, io));
2918
+ case "show": return out({ schedule: describeSchedule(ws, needId(), io) });
2919
+ case "add": { const spec = readSpec(); if (id && spec.id === undefined) spec.id = id; if (id && spec.id !== id) throw scheduleError("E_SCHEDULE_INVALID", `id ${JSON.stringify(spec.id)} in the file does not match ${JSON.stringify(id)}`, { field: "id" }); return out({ schedule: addSchedule(ws, spec, io) }); }
2920
+ case "update": return out({ schedule: updateSchedule(ws, needId(), readSpec(), io) });
2921
+ case "enable": return out({ schedule: setScheduleEnabled(ws, needId(), true, io) });
2922
+ case "disable": return out({ schedule: setScheduleEnabled(ws, needId(), false, io) });
2923
+ case "run": return out(runScheduleNow(ws, needId(), { io, force: args.includes("--force") }));
2924
+ case "remove": return out(removeSchedule(ws, needId(), { force: args.includes("--force") }));
2925
+ case "reconcile": return out(reconcileSchedule(ws, needId(), { io, clear: args.includes("--clear") }));
2926
+ case "tick": {
2927
+ const dryRun = args.includes("--dry-run");
2928
+ if (args.includes("--host")) return out(tickHost({ io, dryRun }));
2929
+ const reg = readRegistry();
2930
+ const considered = withHostLock(() => tickWorkspace(ws, { io, reg, wsList: reg.workspaces.includes(ws) ? reg.workspaces : [...reg.workspaces, ws], dryRun }));
2931
+ return out({ tickedAt: new Date().toISOString(), considered, scheduler: schedulerStatus(ws, io) });
2932
+ }
2933
+ case "host": {
2934
+ const op = args[2];
2935
+ if (op === "install") { registerWorkspace(ws); installHostUnit(); return out({ scheduler: schedulerStatus(ws, io) }); }
2936
+ if (op === "uninstall") { unregisterWorkspace(ws); if (!readRegistry().workspaces.length) uninstallHostUnit(); return out({ scheduler: schedulerStatus(ws, io) }); }
2937
+ if (op === "status") return out({ scheduler: schedulerStatus(ws, io) });
2938
+ throw scheduleError("E_BAD_ARGS", "oats schedule host install|uninstall|status");
2939
+ }
2940
+ default: throw scheduleError("E_BAD_ARGS", "usage: oats schedule list|show <id>|add <id> --file <spec.json>|update <id> --file <spec.json>|enable <id>|disable <id>|run <id> [--force]|remove <id> [--force]|reconcile <id> [--clear]|tick [--dry-run] [--host]|host install|uninstall|status [--dir <workspace>|--server <id>] [--json]");
2941
+ }
2942
+ } catch (e) { cmdFail(e.code || "E_SCHEDULE_FAILED", e.message); }
2943
+ }
2944
+
2850
2945
  async function sessionCmd() {
2851
2946
  try {
2852
2947
  const home = flag("home");
@@ -2866,7 +2961,19 @@ async function sessionCmd() {
2866
2961
  if (file === true) throw Object.assign(new Error("--text-file needs a path"), { code: "E_BAD_ARGS" });
2867
2962
  if (!file && process.stdin.isTTY) throw Object.assign(new Error("provide --text-file or pipe input on stdin"), { code: "E_BAD_ARGS" });
2868
2963
  result = inputInstanceSession(home, readFileSync(file || 0, "utf8"));
2869
- } 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" });
2964
+ } else if (args[1] === "receive") {
2965
+ // Bytes arrive on stdin (the routed upload pipes them through ssh),
2966
+ // collected event-driven and bounded before anything is written.
2967
+ const name = flag("name");
2968
+ if (!name || name === true) throw Object.assign(new Error("session receive needs --name <file name>"), { code: "E_BAD_ARGS" });
2969
+ if (!home || home === true) throw Object.assign(new Error("session receive needs --home </absolute/instance>"), { code: "E_BAD_ARGS" });
2970
+ if (process.stdin.isTTY) throw Object.assign(new Error("session receive reads the attachment bytes from stdin"), { code: "E_BAD_ARGS" });
2971
+ result = receiveAttachment(home, name, await readStreamBounded(process.stdin, MAX_ATTACHMENT_BYTES));
2972
+ } else if (args[1] === "upload") {
2973
+ const file = flag("file");
2974
+ if (!file || file === true) throw Object.assign(new Error("session upload needs --file <local path>"), { code: "E_BAD_ARGS" });
2975
+ result = uploadAttachment({ file, home: home === true ? undefined : home });
2976
+ } 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" });
2870
2977
  if (JSON_MODE) jsonOk(result); else console.log(JSON.stringify(result, null, 2));
2871
2978
  } catch (e) { cmdFail(e.code || "E_SESSION_FAILED", e.message); }
2872
2979
  }
@@ -3161,7 +3268,7 @@ function versionCmd() {
3161
3268
  // on it (an older CLI without the surface must fail closed with a
3162
3269
  // reason, not an argument error). `features`: kernel abilities a peer
3163
3270
  // must see before relying on them (retire-home: retire --home).
3164
- 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"], features: ["retire-home", "session-start"] }));
3271
+ 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"], features: ["retire-home", "session-start", "schedule", "session-upload"], scheduleApi: SCHEDULE_API }));
3165
3272
  return;
3166
3273
  }
3167
3274
  console.log(`@awebai/oats ${OATS_VERSION} (desktop API v1)`);
@@ -3316,6 +3423,32 @@ function serverRouteCmd() {
3316
3423
  if (hr.instance && hr.instance !== inst) console.log(` the harvester ${hr.instance} runs on ${id}; retire it there when it is done, or let it self-retire`);
3317
3424
  return;
3318
3425
  }
3426
+ if (cmd === "schedule") {
3427
+ // Host-owned: the subcommand runs in the server's registered workspace.
3428
+ // A local --file spec is read here and travels inline; the remote
3429
+ // cannot read this machine's files.
3430
+ const rest = [];
3431
+ for (let i = 1; i < args.length; i++) {
3432
+ const a = args[i];
3433
+ if (a === "--server") { i++; continue; }
3434
+ if (a === "--json") continue;
3435
+ if (a === "--file") {
3436
+ const f = args[++i];
3437
+ if (!f || f.startsWith("--")) bail("E_BAD_ARGS", "--file needs a path");
3438
+ if (!existsSync(f)) bail("E_BAD_ARGS", `spec file not found: ${f}`);
3439
+ rest.push("--spec-json", readFileSync(f, "utf8"));
3440
+ continue;
3441
+ }
3442
+ rest.push(a);
3443
+ }
3444
+ let out;
3445
+ try { out = scheduleRemote(id, rest); } catch (e) { bail(e.code || "E_SSH", e.message); }
3446
+ if (out.stderr?.trim()) process.stderr.write(out.stderr.endsWith("\n") ? out.stderr : out.stderr + "\n");
3447
+ if (JSON_MODE) { console.log(JSON.stringify(out.envelope, null, 2)); if (!out.envelope.ok) process.exit(1); return; }
3448
+ if (!out.envelope.ok) die(`${id}: ${out.envelope.error?.message || "schedule command failed"} (${out.envelope.error?.code || "E_REMOTE"})`);
3449
+ console.log(JSON.stringify(out.envelope.result, null, 2));
3450
+ return;
3451
+ }
3319
3452
  if (cmd === "session") {
3320
3453
  const addr = { instance: flag("instance") === true ? undefined : flag("instance"), home: flag("home") === true ? undefined : flag("home") };
3321
3454
  if (args[1] === "inspect") {
@@ -3342,7 +3475,19 @@ function serverRouteCmd() {
3342
3475
  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"})`);
3343
3476
  return;
3344
3477
  }
3345
- 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)");
3478
+ if (args[1] === "upload") {
3479
+ // Bytes stream to `session receive` on the execution host over the
3480
+ // same route as attach; the answer's checksum is verified here.
3481
+ const file = flag("file");
3482
+ if (!file || file === true) bail("E_BAD_ARGS", "session upload needs --file <local path>");
3483
+ let r;
3484
+ try { r = uploadAttachment({ file, server: id, ...addr }); } catch (e) { bail(e.code || "E_UPLOAD_FAILED", e.message); }
3485
+ if (r.stderr) process.stderr.write(r.stderr + "\n");
3486
+ if (JSON_MODE) { jsonOk(r); return; }
3487
+ console.log(`Uploaded ${r.name} (${r.bytes} bytes) to ${r.instance || r.home} on ${id}: ${r.path}`);
3488
+ return;
3489
+ }
3490
+ 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)");
3346
3491
  let route;
3347
3492
  try { route = attachArgv(id, addr, { skipVersionCheck: args.includes("--print") }); }
3348
3493
  catch (e) { bail(e.code || "E_BAD_ARGS", e.message); }
@@ -3365,6 +3510,24 @@ function serverRouteCmd() {
3365
3510
  rest.push("--task", readFileSync(f, "utf8"));
3366
3511
  continue;
3367
3512
  }
3513
+ // A wake spec for a routed spawn travels as validated JSON text; the
3514
+ // host saves it in its own scope. Local files never travel as paths.
3515
+ if (a === "--wake-file") {
3516
+ const f = args[++i];
3517
+ if (!f || f.startsWith("--")) bail("E_BAD_ARGS", "--wake-file needs a path");
3518
+ if (!existsSync(f)) bail("E_BAD_ARGS", `wake file not found: ${f}`);
3519
+ let doc; try { doc = JSON.parse(readFileSync(f, "utf8")); } catch (e) { bail("E_SCHEDULE_INVALID", `--wake-file is not valid JSON: ${e.message}`); }
3520
+ if (!doc || typeof doc !== "object" || !["cron", "tz", "message"].every((k) => typeof doc[k] === "string" && doc[k].trim())) bail("E_SCHEDULE_INVALID", "--wake-file must hold {cron, tz, message, enabled}");
3521
+ rest.push("--wake-json", JSON.stringify({ cron: doc.cron, tz: doc.tz, message: doc.message, enabled: doc.enabled === undefined ? true : doc.enabled }));
3522
+ continue;
3523
+ }
3524
+ if (a === "--wake-message-file") {
3525
+ const f = args[++i];
3526
+ if (!f || f.startsWith("--")) bail("E_BAD_ARGS", "--wake-message-file needs a path");
3527
+ if (!existsSync(f)) bail("E_BAD_ARGS", `wake message file not found: ${f}`);
3528
+ rest.push("--wake-message", readFileSync(f, "utf8"));
3529
+ continue;
3530
+ }
3368
3531
  rest.push(a);
3369
3532
  }
3370
3533
  let routed;
@@ -3426,10 +3589,10 @@ try {
3426
3589
  // and exits 0 BEFORE any dispatch: a fresh operator inspects --help before
3427
3590
  // using a command, and `install --help` once ran the bare restore while
3428
3591
  // `okf harvest --help` spawned a harvester (BeadHub, 2026-09-05).
3429
- const KERNEL_COMMANDS = new Set(["capture", "config", "create", "doctor", "experimental", "init", "inject", "install", "list", "migrate", "pane", "recall", "remove", "retire", "root", "server", "session", "setup", "spawn", "status", "trust", "type", "update", "use", "version"]);
3592
+ 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"]);
3430
3593
  const wantsHelp = args.slice(1).some((a) => a === "--help" || a === "-h");
3431
3594
  if (cmd && KERNEL_COMMANDS.has(cmd) && wantsHelp) { if (JSON_MODE) { jsonOk({ command: cmd, usage: usageLinesFor(cmd) }); process.exit(0); } usageFor(cmd); process.exit(0); }
3432
- if (flag("server") !== undefined && ["spawn", "retire", "status", "session", "okf"].includes(cmd)) serverRouteCmd();
3595
+ if (flag("server") !== undefined && ["spawn", "retire", "status", "session", "okf", "schedule"].includes(cmd)) serverRouteCmd();
3433
3596
  else if (cmd === "server") serverCmd();
3434
3597
  else if (cmd === "doctor") {
3435
3598
  const doctorDir = args[1] && !args[1].startsWith("--") ? args[1] : undefined;
@@ -3459,6 +3622,7 @@ else if (cmd === "version" || cmd === "--version" || cmd === "-v") versionCmd();
3459
3622
  // Same rule as the inner catch: a typed CLI failure surfaces with its own code
3460
3623
  // through the shared boundary, never re-badged as a spawn-mechanism failure.
3461
3624
  else if (cmd === "session") await sessionCmd();
3625
+ else if (cmd === "schedule") scheduleCmd();
3462
3626
  else if (cmd === "spawn") { try { spawnCmd(); } catch (e) { if (TYPED_CLI_FAILURES.has(e?.code)) throw e; if (JSON_MODE) jsonFail("E_SPAWN_FAILED", e.message || e); throw e; } }
3463
3627
  else if (cmd === "retire") retireCmd();
3464
3628
  else if (cmd === "create") createCmd();
@@ -3525,11 +3689,27 @@ Usage:
3525
3689
  oats session start --server <id> start a stopped remote instance in its existing home
3526
3690
  --instance <name> | --home <abs> over its saved route; the server must advertise
3527
3691
  [--model <m>] [--json] session-start (oats 0.22.9 or later)
3692
+ oats session upload --server <id> copy a local file into a remote instance's private
3693
+ --instance <name> | --home <abs> attachments over its saved route (bytes stream on
3694
+ --file <path> [--json] ssh stdin; sha256 verified); the server must
3695
+ advertise session-upload (oats 0.22.13 or later)
3528
3696
  oats create <name> [--local] create an agent soul; --local = full
3529
3697
  [--description <d>] [--repo <r>] soul under local-agents/ (uncommitted,
3530
3698
  [--work <mode>] [--runtime pi|claude|codex] gitignored; same memory + lifecycle)
3531
3699
  [--model <m>] [--yolo|--no-yolo] [--instructions-file <f>]
3532
3700
  oats session inspect|input|attach --home <absolute-home> [--text-file <path>] [--json]
3701
+ oats schedule list|show <id>|add <id> --file <spec.json>|update <id> --file <spec.json>
3702
+ enable|disable|run|remove|reconcile <id> workspace-scoped, host-owned schedules (spawn,
3703
+ tick [--dry-run] [--host] command or wake jobs on a five-field cron with an
3704
+ host install|uninstall|status explicit IANA tz; see docs/schedules.md); --server
3705
+ routes to that host's workspace
3706
+ oats spawn ... --wake-file <json> | --wake-every <N> --wake-message <text> save a wake schedule
3707
+ bound to the new instance's home (docs/schedules.md)
3708
+ oats session upload --file <path> store a copy of a local file as a private attachment
3709
+ --home <absolute-home> [--json] in the instance home (.oats-attachments/); answers
3710
+ {path, bytes, sha256}; never types into the session
3711
+ oats session receive --home <abs> --name store attachment bytes read from stdin (the routed
3712
+ <file> [--json] upload's remote half)
3533
3713
  oats session start --home <absolute-home> start a STOPPED instance again in its existing home
3534
3714
  [--model <m>] [--json] (same identity, worktree, notes and launch env; no
3535
3715
  spawn hooks); --model replaces the recorded model
@@ -0,0 +1,131 @@
1
+ # Architecture reassessment: usable agents with replaceable services
2
+
3
+ Assessment of main `76c0ea4`, September 7, 2026. Requested by Juan after
4
+ operating the Desktop; incorporates Pepe's terminal, shortcut, split and soul
5
+ management feedback. This records the direction and implementation gaps, not a
6
+ claim that every item below has shipped.
7
+
8
+ The requirements authority for this assessment is Juan's September 3
9
+ “Architecture simplification and generlization” in his OATS project KB:
10
+ souls carry instructions and capabilities; a harvester converts ephemeral
11
+ state into knowledge accessible through the selected knowledge capability;
12
+ packages distribute souls and capabilities; OATS constructs and operates
13
+ instances across runtimes and platforms. The private Bookshelf README points
14
+ to September 1's adoption/sovereignty strategy. Its relevant constraint is that
15
+ changing a runtime or service provider must not erase the team's relationships
16
+ or working knowledge. The older August proposals about genomes, clothes and
17
+ turn-record experiments are background, not reasons to expand this release.
18
+
19
+ ## Decision
20
+
21
+ Keep the existing package and capability-layer mechanism. Correct the places
22
+ where the CLI and GUI bypass it. The architecture is broadly right; the
23
+ operator experience and some service boundaries are incomplete. More
24
+ abstractions will not fix a terminal that cannot accept a screenshot.
25
+
26
+ | Component | Owns | Must not assume |
27
+ |---|---|---|
28
+ | OATS kernel | Soul/instance construction, configuration, lifecycle, host placement, scheduling, capability resolution | A particular knowledge provider, messaging service, or GUI |
29
+ | Session backend | Durable terminal, attach/detach, resize, literal input, observation | OATS soul/package semantics or task success |
30
+ | Knowledge capability | Knowledge access and representation; its harvester and promotion policy | Every installation uses OKF or stores memory in the same files |
31
+ | Messaging capability / aweb | Durable messages, identities, event subscriptions and delivery policy | A particular model harness or an open Desktop window |
32
+ | Task capability | Durable work and task semantics | A particular terminal backend |
33
+ | Desktop | Inspect effective configuration; explicit operator actions; terminal input, files, layout | Provider names, SSH commands, or a second lifecycle implementation |
34
+
35
+ Scheduling belongs to OATS, and a scheduled harvest is one use of scheduling.
36
+ Harvesting behavior belongs to the knowledge capability. Starting an agent,
37
+ writing a prompt, and producing reviewed knowledge are different outcomes.
38
+ The UI must retain those distinctions.
39
+
40
+ ## What already fits, and what does not
41
+
42
+ `lib/core.mjs` already selects exclusive knowledge/messaging/tasks layers by
43
+ manifest, resolves scoped capability settings, dispatches lifecycle hooks, and
44
+ materializes runtime instructions. Jira and Linear demonstrate that a layer
45
+ can have alternative implementations. OKF's harvester is already an exported
46
+ capability agent. There is no reason to replace this machinery.
47
+
48
+ The remaining coupling is concrete:
49
+
50
+ - The retire receipt reads `hookResults.meta["oats.okf"].harvested`, although
51
+ current OKF does not supply a retire hook. Remove the dead provider-specific
52
+ inference; do not invent successful harvesting at retirement.
53
+ - Remote capability routing recognizes `okf harvest` specifically in
54
+ `bin/oats.mjs` and `lib/servers.mjs`.
55
+ - Desktop harvest actions and scheduled-harvest definitions construct
56
+ `oats okf harvest`; the editor recognizes that exact argv.
57
+ - The “brain” view presents `STATE.md`, `log.md` and `notes/` as universal
58
+ knowledge structure. These are conventions of the current setup.
59
+
60
+ The next service contract should expose the **effective knowledge provider and
61
+ its supported operations for a specific soul/home**. Discovering a capability
62
+ command namespace alone is insufficient: it does not promise a `harvest` verb
63
+ or a shared file layout. A provider may offer harvest, different operations,
64
+ or read-only access. Desktop must show only declared operations and route
65
+ execution through the selected CLI capability. The same resolution must apply
66
+ locally, remotely, and when a scheduled operation runs.
67
+
68
+ Keep that change small: explicit operation metadata, scoped discovery, and one
69
+ invocation route using the existing capability engine. Do not add a plugin
70
+ framework, generic workflow language, or a second knowledge store. Demonstrate
71
+ replaceability with a tiny alternative-provider fixture that uses a different
72
+ command and storage layout; a full second product is unnecessary.
73
+
74
+ ## tmux and Herdr
75
+
76
+ The current kernel defaults to a shared tmux session (`pi-agents`) with a
77
+ window per instance. An agent does not require another tmux server. The GUI
78
+ creates a temporary session linking only the selected window. That isolates
79
+ viewers: switching a terminal elsewhere cannot redirect a GUI tab to another
80
+ agent, and an agent exiting cannot silently expose a sibling under its label.
81
+ The `oatsdesk-*` name in Juan's screenshot is this viewer. Its status bar can
82
+ be hidden with a viewer-local setting. Do not alter global tmux settings or
83
+ merge/move live agent sessions to solve a display defect.
84
+
85
+ Herdr 0.8.2 is a separate terminal runtime, not a tmux-compatible superset.
86
+ Its documented direct terminal attachment, socket API, events and SSH support
87
+ are useful. Its semantic agent state comes from detection and/or integrations;
88
+ it is richer evidence, not proof that a particular message was consumed.
89
+ The [socket API](https://herdr.dev/docs/socket-api/) explicitly distinguishes
90
+ agent-state waits from arbitrary command completion. The
91
+ [remote documentation](https://herdr.dev/docs/persistence-remote/) also
92
+ separates a local thin client from running a client entirely on the server;
93
+ only the former can directly bridge the local desktop clipboard.
94
+
95
+ Keep one OATS session contract and both existing adapters for now. Do not add
96
+ another supervisor above them. Qualify Herdr's actual literal multi-line
97
+ input, exact occupant checks, detach, resize, process exit and remote behavior
98
+ before selecting it as the default for new instances. Inspect `pane run`
99
+ implementation before replacing it merely because its name sounds like shell
100
+ execution. Removing tmux is a later deployment choice, not a prerequisite for
101
+ consistent UI or remote agents. Existing tmux agents stay where they are.
102
+
103
+ ## Desktop correction sequence
104
+
105
+ 1. **Terminal essentials.** Hide viewer chrome, accept dropped files and pasted
106
+ images without pressing Enter, upload remote files to the execution host,
107
+ preserve the destination tab across async work, and show transfer failures.
108
+ Keep a single GUI and bounded viewers. This is the immediate implementation.
109
+ 2. **Keyboard and splits.** Restore Ctrl+Tab navigation inside Mac terminals;
110
+ let an already-open terminal fill an empty split without a second attach;
111
+ expose draggable and keyboard-operable separators. Follow up specific
112
+ keyboard reports with real input tests, especially terminal editing keys
113
+ and non-US layouts. App shortcuts must not silently steal terminal edits.
114
+ 3. **Souls & capabilities.** Replace the launch-centric roster with a management
115
+ surface showing souls, installed capabilities, effective layer providers,
116
+ source/version, scoped activation and runtime defaults. Inspect and launch
117
+ are distinct actions. Edits use existing CLI operations with the exact scope
118
+ visible; installing a capability must not silently activate it. This is not
119
+ a marketplace or Team Builder project.
120
+ 4. **Provider-neutral operations.** Introduce the scoped operation contract
121
+ above, then use it for manual harvest, schedules and knowledge inspection.
122
+ No second hardcoded default is accepted as a generalization.
123
+ 5. **Operating rollout.** Enable schedules per team with the owner's learning
124
+ policy. Capture-lock recovery is a separate reliability issue affecting
125
+ unattended harvesting; it does not block arbitrary scheduled wakes.
126
+
127
+ Acceptance must include real Electron file objects, actual terminal bytes,
128
+ remote transfer hashes, split focus/resize without extra PTYs, and a provider
129
+ replacement fixture. Unit tests of mocked argv alone do not establish these
130
+ boundaries. Reuse the existing agents and GUI sparingly; no model is needed to
131
+ exercise attachment transport or terminal layout.
package/docs/desktop.md CHANGED
@@ -74,8 +74,8 @@ app focus, and Retry. Until a compatible CLI is verified, the Soul roster's
74
74
  **Spawn** buttons are disabled behind one card showing what was detected,
75
75
  what is required, **Choose oats…** (pick the binary yourself — the choice
76
76
  persists), **Retry**, a docs link, and the copyable install command. The
77
- app never installs anything itself. (Memory harvest runs through the same
78
- CLI boundary in the backend; it has no dedicated button in this release.)
77
+ app never installs the CLI itself. Memory harvest runs through the same
78
+ CLI boundary and is available from an instance's action menu.
79
79
 
80
80
  The probe/mutation contract is specified in
81
81
  [desktop-cli-api.md](desktop-cli-api.md).
@@ -95,6 +95,32 @@ first-class: they appear in the roster with a `local` chip, their brains
95
95
  and knowledge render, and they spawn like any other soul. Launch flags for
96
96
  scripted use: `--dir <workspace>` and `OATS_DESKTOP_PORT`.
97
97
 
98
+ ## Scheduling agents and wake messages
99
+
100
+ Open **Schedules** in the selected workspace to launch a new agent on a cron,
101
+ wake an existing agent with a message, or harvest its knowledge. **Schedule…**
102
+ on a Soul roster card preselects that soul. Choose the task or message, repeat
103
+ pattern and time zone; new agent jobs also offer runtime, model, permissions
104
+ and session backend. The list shows the next run, last observed outcome and
105
+ whether the host scheduler is enabled. Pause, edit, run now and delete operate
106
+ on that workspace's saved jobs. Launching an agent is reported separately from
107
+ the end of its run; neither means its task succeeded.
108
+
109
+ The Spawn dialog also has an optional **Recurring wake-up** setting. It binds
110
+ the schedule to the newly created home, preserving that agent's identity and
111
+ work. A wake starts that same home if it is stopped, then sends the saved
112
+ message through its terminal when ready. It sends no interrupt; the harness
113
+ decides when to process submitted input. Missed cron times are skipped. If the
114
+ agent is created but its wake schedule cannot be saved, the dialog reports both
115
+ facts and directs you to Schedules without spawning another agent.
116
+
117
+ Use **Enable host scheduler** when the view reports that the timer or workspace
118
+ registration is missing. One host timer handles its registered workspaces with
119
+ a shared limit on scheduled agent launches. The GUI can then be closed. For a
120
+ registered remote workspace the timer and definitions live on that server, so
121
+ they do not depend on the Mac staying awake. See [Schedules](schedules.md) for
122
+ the CLI, cron semantics, observed outcomes and recovery commands.
123
+
98
124
  ## Migrating from the web panel / TUI pane
99
125
 
100
126
  0.18.2 removes the legacy `oats.web` browser panel, `oats pane`, and the
@@ -185,3 +211,19 @@ certificate auto-discovery disabled) and
185
211
  marked build-verify mode (inventory + strict codesign verification +
186
212
  node-pty ABI, no GUI launch); a local
187
213
  interactive run may also exercise the launch phase.
214
+
215
+ ### Attach files and screenshots
216
+
217
+ Drop a file onto an agent terminal to insert its path into that agent's draft.
218
+ Pasting an image from the clipboard uses the same attachment path. Neither
219
+ operation presses Enter. Text paste continues to use the terminal's normal
220
+ paste behavior. A drop targets the pane under the pointer, including a visible
221
+ pane in a split.
222
+
223
+ Local files are referenced in place. Clipboard images are saved privately in
224
+ Desktop's application-data `attachments` directory and retained so an agent can
225
+ read them later. For remote terminals, Desktop calls the installed CLI's
226
+ `session upload` operation; it inserts the returned path only after the file
227
+ has reached the execution host. Both CLI installations must advertise
228
+ `session-upload`. A failed transfer leaves the draft unchanged and shows an
229
+ error in the terminal. Each drop/paste accepts up to 16 files totaling 25 MB.
@@ -181,6 +181,27 @@ pane in that exact window. Herdr additionally verifies the original terminal ID.
181
181
  The broker owns busy/approval policy and must not interpret `submitted` as
182
182
  processing acknowledgement.
183
183
 
184
+ ### Attachments
185
+
186
+ A viewer that drops a file or pastes an image gives the agent a path, never
187
+ terminal input. `oats session upload --file <local> --home <abs>` stores a
188
+ copy as a private file under `<home>/.oats-attachments/` (directory 0700,
189
+ file 0600, `name-2` on collision) and answers `{path, bytes, sha256}`; the
190
+ caller pastes `path` into the still-live session itself. With `--server <id>`
191
+ and `--instance <name>` (or `--home`), the same saved route as attach is
192
+ resolved, the remote must list `session-upload` in both its `remote` and
193
+ `features` probe arrays, and the bytes travel on ssh stdin into `oats session
194
+ receive --home <abs> --name <file>` on the execution host. Each side holds
195
+ the whole file in memory up to the 64 MiB kernel bound (this is a bounded
196
+ transfer, not end-to-end streaming); the receiver reads stdin event-driven,
197
+ refuses above the bound before writing, and allocates the destination
198
+ exclusively (`name-2`, `name-3` when taken), so simultaneous uploads of one
199
+ name never overwrite each other and a planted symlink is never followed. The
200
+ attachments directory must be a real directory inside the home. The local
201
+ side refuses the result unless the remote's size and sha256 equal the local
202
+ file's. A remote file is never a local path over SSH. Attachments live and
203
+ die with the home.
204
+
184
205
  Capability spawn hooks register a pending home before runtime allocation;
185
206
  inspection becomes available once its receipt is persisted. Retire hooks
186
207
  unregister after quiescence. The broker must tolerate this lifecycle order and
@@ -0,0 +1,49 @@
1
+ # OATS v0.22.11
2
+
3
+ Schedules: cron-driven agent work that runs on the execution host, from
4
+ the CLI and from Desktop, including while the GUI is closed.
5
+
6
+ ## Schedules (kernel)
7
+
8
+ `oats schedule` keeps a per-workspace `oats-schedules.json` of jobs of three
9
+ kinds: **spawn** launches a soul as a fresh disposable instance with a task,
10
+ **command** runs an `oats` command in a directory (a harvest, for example),
11
+ and **wake** starts a stopped instance if needed and sends it a message
12
+ through its terminal. Cron expressions are five-field with an IANA time
13
+ zone, evaluated by croner; missed minutes are skipped, never caught up.
14
+
15
+ One host timer (`oats schedule host install`: a launchd agent or a systemd
16
+ user timer) ticks every registered workspace once a minute under a shared
17
+ limit on concurrent scheduled launches. A remote workspace's schedules live
18
+ and run on that server; `--server` routes every subcommand there.
19
+ `oats spawn --wake-file <json>` binds a wake schedule to the new home.
20
+
21
+ Outcomes are what the kernel observed, not a claim that a task succeeded:
22
+ launched, active, ended, stopped, launch-failed, delivered, started,
23
+ skipped, and unknown. A launch whose effects the kernel cannot confirm (a
24
+ timed-out or envelope-less command, an incomplete rollback) keeps its slot
25
+ as unknown until `oats schedule reconcile <id>` adopts a named receipt or
26
+ the operator clears it with `--clear`. A wake's launch slot follows the
27
+ runtime through the session start receipt, so a persistent home whose
28
+ process exited frees the slot. Locks are never reclaimed automatically;
29
+ an unreadable or dead owner names the directory to remove.
30
+ `oats version --json` advertises feature `schedule` and `scheduleApi: 1`.
31
+ See docs/schedules.md.
32
+
33
+ ## Schedules (Desktop)
34
+
35
+ A **Schedules** view per workspace: create, edit, pause, run now, check run
36
+ state and delete jobs through a single JSON spec; the Soul roster's
37
+ **Schedule…** preselects the soul; the spawn dialog offers a recurring wake
38
+ for the new agent, forwarded inline to a remote server. Mutations require
39
+ an explicit workspace. The reconcile result and its recovery instructions
40
+ stay visible across polling. **Enable host scheduler** installs the timer
41
+ and registers the workspace. See docs/desktop.md.
42
+
43
+ ## Tests
44
+
45
+ The kernel adds `test/schedule.test.mjs` (fixture homes, injected
46
+ spawn/start/input, dummy commands, fixture-only host unit paths) and
47
+ `test/schedule-session.test.mjs`, which drives a real private tmux socket
48
+ to prove literal delivery, same-minute deduplication and slot release when
49
+ a runtime stops. Nothing in the suites touches a live timer.
@@ -0,0 +1,53 @@
1
+ # OATS v0.22.12
2
+
3
+ The same reviewed content as the unpublished v0.22.11 tag, whose hosted
4
+ run failed in test fixtures before anything was published; those fixtures
5
+ are fixed here.
6
+
7
+ Schedules: cron-driven agent work that runs on the execution host, from
8
+ the CLI and from Desktop, including while the GUI is closed.
9
+
10
+ ## Schedules (kernel)
11
+
12
+ `oats schedule` keeps a per-workspace `oats-schedules.json` of jobs of three
13
+ kinds: **spawn** launches a soul as a fresh disposable instance with a task,
14
+ **command** runs an `oats` command in a directory (a harvest, for example),
15
+ and **wake** starts a stopped instance if needed and sends it a message
16
+ through its terminal. Cron expressions are five-field with an IANA time
17
+ zone, evaluated by croner; missed minutes are skipped, never caught up.
18
+
19
+ One host timer (`oats schedule host install`: a launchd agent or a systemd
20
+ user timer) ticks every registered workspace once a minute under a shared
21
+ limit on concurrent scheduled launches. A remote workspace's schedules live
22
+ and run on that server; `--server` routes every subcommand there.
23
+ `oats spawn --wake-file <json>` binds a wake schedule to the new home.
24
+
25
+ Outcomes are what the kernel observed, not a claim that a task succeeded:
26
+ launched, active, ended, stopped, launch-failed, delivered, started,
27
+ skipped, and unknown. A launch whose effects the kernel cannot confirm (a
28
+ timed-out or envelope-less command, an incomplete rollback) keeps its slot
29
+ as unknown until `oats schedule reconcile <id>` adopts a named receipt or
30
+ the operator clears it with `--clear`. A wake's launch slot follows the
31
+ runtime through the session start receipt, so a persistent home whose
32
+ process exited frees the slot. Locks are never reclaimed automatically;
33
+ an unreadable or dead owner names the directory to remove.
34
+ `oats version --json` advertises feature `schedule` and `scheduleApi: 1`.
35
+ See docs/schedules.md.
36
+
37
+ ## Schedules (Desktop)
38
+
39
+ A **Schedules** view per workspace: create, edit, pause, run now, check run
40
+ state and delete jobs through a single JSON spec; the Soul roster's
41
+ **Schedule…** preselects the soul; the spawn dialog offers a recurring wake
42
+ for the new agent, forwarded inline to a remote server. Mutations require
43
+ an explicit workspace. The reconcile result and its recovery instructions
44
+ stay visible across polling. **Enable host scheduler** installs the timer
45
+ and registers the workspace. See docs/desktop.md.
46
+
47
+ ## Tests
48
+
49
+ The kernel adds `test/schedule.test.mjs` (fixture homes, injected
50
+ spawn/start/input, dummy commands, fixture-only host unit paths) and
51
+ `test/schedule-session.test.mjs`, which drives a real private tmux socket
52
+ to prove literal delivery, same-minute deduplication and slot release when
53
+ a runtime stops. Nothing in the suites touches a live timer.