@awebai/oats 0.22.9 → 0.22.12

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,10 @@ 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";
44
46
 
45
47
  const args = process.argv.slice(2);
46
48
  const cmd = args[0];
@@ -2754,6 +2756,34 @@ function spawnCmd() {
2754
2756
  const relativeRoot = flag("relative-root");
2755
2757
  if (relativeRoot !== undefined && (relativeRoot === true || !String(relativeRoot).trim())) bail("E_BAD_ARGS", "--relative-root needs an agents-root path");
2756
2758
  if (relativeRoot && !relativeTo) bail("E_BAD_ARGS", "--relative-root only qualifies --relative-to/--parent");
2759
+ // Wake at launch: --wake-file <private JSON {cron, tz, message, enabled}>
2760
+ // is the backend bridge; --wake-every/--wake-cron/--wake-tz/--wake-message
2761
+ // (or --wake-message-file) are the human sugar for the same object. The
2762
+ // wake job is saved AFTER a successful spawn, bound to the returned home.
2763
+ let wake;
2764
+ try {
2765
+ const wakeFile = flag("wake-file");
2766
+ if (wakeFile === true) bail("E_BAD_ARGS", "--wake-file needs a path");
2767
+ const wakeJson = flag("wake-json");
2768
+ if (wakeJson === true) bail("E_BAD_ARGS", "--wake-json needs the wake object as JSON text");
2769
+ if (wakeJson) {
2770
+ let doc; try { doc = JSON.parse(wakeJson); } catch (e) { bail("E_SCHEDULE_INVALID", `--wake-json is not valid JSON: ${e.message}`); }
2771
+ if (!doc || typeof doc !== "object") bail("E_SCHEDULE_INVALID", "--wake-json must hold {cron, tz, message, enabled}");
2772
+ wake = { cron: doc.cron, tz: doc.tz, message: doc.message, enabled: doc.enabled === undefined ? true : doc.enabled };
2773
+ } else if (wakeFile) {
2774
+ if (!existsSync(wakeFile)) bail("E_BAD_ARGS", `--wake-file not found: ${wakeFile}`);
2775
+ let doc; try { doc = JSON.parse(readFileSync(wakeFile, "utf8")); } catch (e) { bail("E_SCHEDULE_INVALID", `--wake-file is not valid JSON: ${e.message}`); }
2776
+ if (!doc || typeof doc !== "object") bail("E_SCHEDULE_INVALID", "--wake-file must hold {cron, tz, message, enabled}");
2777
+ wake = { cron: doc.cron, tz: doc.tz, message: doc.message, enabled: doc.enabled === undefined ? true : doc.enabled };
2778
+ } else if (flag("wake-every") !== undefined || flag("wake-cron") !== undefined) {
2779
+ const mf = flag("wake-message-file");
2780
+ if (mf === true) bail("E_BAD_ARGS", "--wake-message-file needs a path");
2781
+ if (mf && !existsSync(mf)) bail("E_BAD_ARGS", `--wake-message-file not found: ${mf}`);
2782
+ const message = mf ? readFileSync(mf, "utf8") : flag("wake-message") === true ? undefined : flag("wake-message");
2783
+ 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 });
2784
+ }
2785
+ if (wake && (typeof wake.message !== "string" || !wake.message.trim())) bail("E_SCHEDULE_INVALID", "wake message: non-empty text is required");
2786
+ } catch (e) { if (e?.code?.startsWith?.("E_")) bail(e.code, e.message); throw e; }
2757
2787
  let r;
2758
2788
  try {
2759
2789
  r = spawnInstance(root, agent, {
@@ -2770,11 +2800,19 @@ function spawnCmd() {
2770
2800
  if (TYPED_CLI_FAILURES.has(e?.code)) throw e;
2771
2801
  bail(e.code === "E_RELATIVE_AMBIGUOUS" ? "E_RELATIVE_AMBIGUOUS" : "E_SPAWN_FAILED", e.message || e); throw e;
2772
2802
  }
2803
+ // The instance exists from here on: a failed wake save is reported beside
2804
+ // the full receipt, never hidden, and never causes a second spawn.
2805
+ let wakeSchedule, wakeScheduleError;
2806
+ if (wake) {
2807
+ try { wakeSchedule = saveWakeForHome(scheduleScopeOf(workspaceOf(root)), { instance: r.instance, home: r.home, wake }); }
2808
+ catch (e) { wakeScheduleError = { code: e.code || "E_SCHEDULE_FAILED", message: e.message }; r.warnings = [...(r.warnings || []), `wake schedule NOT saved: ${e.message}`]; }
2809
+ }
2773
2810
  if (JSON_MODE) {
2774
2811
  // Desktop CLI API v1 spawn result — a FIXED shape (see docs/desktop-cli-api.md).
2775
2812
  jsonOk({
2776
2813
  instance: r.instance, agent: r.agent, home: r.home, work: r.work,
2777
2814
  branch: r.branch || null, launched: r.launched, warnings: r.warnings || [],
2815
+ ...(wakeSchedule ? { wakeSchedule } : {}), ...(wakeScheduleError ? { wakeScheduleError } : {}),
2778
2816
  tmux: r.tmux || null, repo: r.repo || null, runtime: r.runtime || null,
2779
2817
  model: r.model || null, parent: r.parentInstance || null,
2780
2818
  sibling: r.siblingInstance || null, relation: r.relation || null,
@@ -2786,6 +2824,8 @@ function spawnCmd() {
2786
2824
  }
2787
2825
  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
2826
  console.log(` home: ${shortPath(r.home)}`);
2827
+ if (wakeSchedule) console.log(` wake: schedule ${wakeSchedule.id} (${wakeSchedule.cron} ${wakeSchedule.tz}), next ${wakeSchedule.nextRun || "disabled"}`);
2828
+ 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
2829
  if (!r.launched) console.log(` launch: (cd ${shortPath(r.home)} && ${r.command})`);
2790
2830
  for (const w of r.warnings || []) console.log(` WARNING: ${w}`);
2791
2831
  console.log(` attach: ${r.attach}`);
@@ -2809,7 +2849,11 @@ function retireCmd() {
2809
2849
  // Stdout carries only the envelope in JSON mode (the Desktop parses it).
2810
2850
  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
2851
  }
2852
+ const retiringHome = homeFlag || findInstanceHome(root, name);
2812
2853
  const r = retireInstance(root, name, { home: homeFlag, self: isSelf, deleteBranch: args.includes("--delete-branch"), keepDir: args.includes("--keep-dir"), force: args.includes("--force") });
2854
+ // A retired home's wake jobs are forgotten (definitions only; nothing is
2855
+ // stopped by this); a deferred self-retire keeps them until the home is gone.
2856
+ 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
2857
  // Deferred self-retire: nothing has been inspected, run, or removed yet. The
2814
2858
  // caller's window dies first; a detached process then retires the instance
2815
2859
  // as an external operator and writes its outcome beside the home.
@@ -2847,6 +2891,56 @@ function retireCmd() {
2847
2891
  if (isSelf) console.log("This window dies in ~8s — say any goodbyes now.");
2848
2892
  }
2849
2893
 
2894
+ /** `oats schedule ...`: workspace-scoped definitions, host-owned execution
2895
+ * (lib/schedule.mjs). Every subcommand answers the envelope; nothing here
2896
+ * launches unless a job is due or run-now is asked. */
2897
+ function scheduleCmd() {
2898
+ const sub = args[1];
2899
+ const id = args[2] && !args[2].startsWith("--") ? args[2] : undefined;
2900
+ // One schedule-owning scope for a directory: the team workspace (the
2901
+ // config level declaring the team), else the outermost config level.
2902
+ const ws = scheduleScopeOf(dirFlag());
2903
+ const io = { hostStatus: () => hostUnitStatus() };
2904
+ const out = (result) => { if (JSON_MODE) jsonOk(result); else console.log(JSON.stringify(result, null, 2)); };
2905
+ const readSpec = () => {
2906
+ const inline = flag("spec-json");
2907
+ 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" }); } }
2908
+ const f = flag("file");
2909
+ if (!f || f === true) throw scheduleError("E_BAD_ARGS", "--file <spec.json> is required (a private JSON file with the definition)");
2910
+ if (!existsSync(f)) throw scheduleError("E_BAD_ARGS", `spec file not found: ${f}`);
2911
+ try { return JSON.parse(readFileSync(f, "utf8")); } catch (e) { throw scheduleError("E_SCHEDULE_INVALID", `${f} is not valid JSON: ${e.message}`, { field: "file" }); }
2912
+ };
2913
+ const needId = () => { if (!id) throw scheduleError("E_BAD_ARGS", `oats schedule ${sub} <id>`); return id; };
2914
+ try {
2915
+ switch (sub) {
2916
+ case "list": return out(listSchedules(ws, io));
2917
+ case "show": return out({ schedule: describeSchedule(ws, needId(), io) });
2918
+ 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) }); }
2919
+ case "update": return out({ schedule: updateSchedule(ws, needId(), readSpec(), io) });
2920
+ case "enable": return out({ schedule: setScheduleEnabled(ws, needId(), true, io) });
2921
+ case "disable": return out({ schedule: setScheduleEnabled(ws, needId(), false, io) });
2922
+ case "run": return out(runScheduleNow(ws, needId(), { io, force: args.includes("--force") }));
2923
+ case "remove": return out(removeSchedule(ws, needId(), { force: args.includes("--force") }));
2924
+ case "reconcile": return out(reconcileSchedule(ws, needId(), { io, clear: args.includes("--clear") }));
2925
+ case "tick": {
2926
+ const dryRun = args.includes("--dry-run");
2927
+ if (args.includes("--host")) return out(tickHost({ io, dryRun }));
2928
+ const reg = readRegistry();
2929
+ const considered = withHostLock(() => tickWorkspace(ws, { io, reg, wsList: reg.workspaces.includes(ws) ? reg.workspaces : [...reg.workspaces, ws], dryRun }));
2930
+ return out({ tickedAt: new Date().toISOString(), considered, scheduler: schedulerStatus(ws, io) });
2931
+ }
2932
+ case "host": {
2933
+ const op = args[2];
2934
+ if (op === "install") { registerWorkspace(ws); installHostUnit(); return out({ scheduler: schedulerStatus(ws, io) }); }
2935
+ if (op === "uninstall") { unregisterWorkspace(ws); if (!readRegistry().workspaces.length) uninstallHostUnit(); return out({ scheduler: schedulerStatus(ws, io) }); }
2936
+ if (op === "status") return out({ scheduler: schedulerStatus(ws, io) });
2937
+ throw scheduleError("E_BAD_ARGS", "oats schedule host install|uninstall|status");
2938
+ }
2939
+ 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]");
2940
+ }
2941
+ } catch (e) { cmdFail(e.code || "E_SCHEDULE_FAILED", e.message); }
2942
+ }
2943
+
2850
2944
  async function sessionCmd() {
2851
2945
  try {
2852
2946
  const home = flag("home");
@@ -3161,7 +3255,7 @@ function versionCmd() {
3161
3255
  // on it (an older CLI without the surface must fail closed with a
3162
3256
  // reason, not an argument error). `features`: kernel abilities a peer
3163
3257
  // 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"] }));
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 }));
3165
3259
  return;
3166
3260
  }
3167
3261
  console.log(`@awebai/oats ${OATS_VERSION} (desktop API v1)`);
@@ -3316,6 +3410,32 @@ function serverRouteCmd() {
3316
3410
  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
3411
  return;
3318
3412
  }
3413
+ if (cmd === "schedule") {
3414
+ // Host-owned: the subcommand runs in the server's registered workspace.
3415
+ // A local --file spec is read here and travels inline; the remote
3416
+ // cannot read this machine's files.
3417
+ const rest = [];
3418
+ for (let i = 1; i < args.length; i++) {
3419
+ const a = args[i];
3420
+ if (a === "--server") { i++; continue; }
3421
+ if (a === "--json") continue;
3422
+ if (a === "--file") {
3423
+ const f = args[++i];
3424
+ if (!f || f.startsWith("--")) bail("E_BAD_ARGS", "--file needs a path");
3425
+ if (!existsSync(f)) bail("E_BAD_ARGS", `spec file not found: ${f}`);
3426
+ rest.push("--spec-json", readFileSync(f, "utf8"));
3427
+ continue;
3428
+ }
3429
+ rest.push(a);
3430
+ }
3431
+ let out;
3432
+ try { out = scheduleRemote(id, rest); } catch (e) { bail(e.code || "E_SSH", e.message); }
3433
+ if (out.stderr?.trim()) process.stderr.write(out.stderr.endsWith("\n") ? out.stderr : out.stderr + "\n");
3434
+ if (JSON_MODE) { console.log(JSON.stringify(out.envelope, null, 2)); if (!out.envelope.ok) process.exit(1); return; }
3435
+ if (!out.envelope.ok) die(`${id}: ${out.envelope.error?.message || "schedule command failed"} (${out.envelope.error?.code || "E_REMOTE"})`);
3436
+ console.log(JSON.stringify(out.envelope.result, null, 2));
3437
+ return;
3438
+ }
3319
3439
  if (cmd === "session") {
3320
3440
  const addr = { instance: flag("instance") === true ? undefined : flag("instance"), home: flag("home") === true ? undefined : flag("home") };
3321
3441
  if (args[1] === "inspect") {
@@ -3365,6 +3485,24 @@ function serverRouteCmd() {
3365
3485
  rest.push("--task", readFileSync(f, "utf8"));
3366
3486
  continue;
3367
3487
  }
3488
+ // A wake spec for a routed spawn travels as validated JSON text; the
3489
+ // host saves it in its own scope. Local files never travel as paths.
3490
+ if (a === "--wake-file") {
3491
+ const f = args[++i];
3492
+ if (!f || f.startsWith("--")) bail("E_BAD_ARGS", "--wake-file needs a path");
3493
+ if (!existsSync(f)) bail("E_BAD_ARGS", `wake file not found: ${f}`);
3494
+ let doc; try { doc = JSON.parse(readFileSync(f, "utf8")); } catch (e) { bail("E_SCHEDULE_INVALID", `--wake-file is not valid JSON: ${e.message}`); }
3495
+ 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}");
3496
+ rest.push("--wake-json", JSON.stringify({ cron: doc.cron, tz: doc.tz, message: doc.message, enabled: doc.enabled === undefined ? true : doc.enabled }));
3497
+ continue;
3498
+ }
3499
+ if (a === "--wake-message-file") {
3500
+ const f = args[++i];
3501
+ if (!f || f.startsWith("--")) bail("E_BAD_ARGS", "--wake-message-file needs a path");
3502
+ if (!existsSync(f)) bail("E_BAD_ARGS", `wake message file not found: ${f}`);
3503
+ rest.push("--wake-message", readFileSync(f, "utf8"));
3504
+ continue;
3505
+ }
3368
3506
  rest.push(a);
3369
3507
  }
3370
3508
  let routed;
@@ -3426,10 +3564,10 @@ try {
3426
3564
  // and exits 0 BEFORE any dispatch: a fresh operator inspects --help before
3427
3565
  // using a command, and `install --help` once ran the bare restore while
3428
3566
  // `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"]);
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"]);
3430
3568
  const wantsHelp = args.slice(1).some((a) => a === "--help" || a === "-h");
3431
3569
  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();
3570
+ if (flag("server") !== undefined && ["spawn", "retire", "status", "session", "okf", "schedule"].includes(cmd)) serverRouteCmd();
3433
3571
  else if (cmd === "server") serverCmd();
3434
3572
  else if (cmd === "doctor") {
3435
3573
  const doctorDir = args[1] && !args[1].startsWith("--") ? args[1] : undefined;
@@ -3459,6 +3597,7 @@ else if (cmd === "version" || cmd === "--version" || cmd === "-v") versionCmd();
3459
3597
  // Same rule as the inner catch: a typed CLI failure surfaces with its own code
3460
3598
  // through the shared boundary, never re-badged as a spawn-mechanism failure.
3461
3599
  else if (cmd === "session") await sessionCmd();
3600
+ else if (cmd === "schedule") scheduleCmd();
3462
3601
  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
3602
  else if (cmd === "retire") retireCmd();
3464
3603
  else if (cmd === "create") createCmd();
@@ -3530,6 +3669,13 @@ Usage:
3530
3669
  [--work <mode>] [--runtime pi|claude|codex] gitignored; same memory + lifecycle)
3531
3670
  [--model <m>] [--yolo|--no-yolo] [--instructions-file <f>]
3532
3671
  oats session inspect|input|attach --home <absolute-home> [--text-file <path>] [--json]
3672
+ oats schedule list|show <id>|add <id> --file <spec.json>|update <id> --file <spec.json>
3673
+ enable|disable|run|remove|reconcile <id> workspace-scoped, host-owned schedules (spawn,
3674
+ tick [--dry-run] [--host] command or wake jobs on a five-field cron with an
3675
+ host install|uninstall|status explicit IANA tz; see docs/schedules.md); --server
3676
+ routes to that host's workspace
3677
+ oats spawn ... --wake-file <json> | --wake-every <N> --wake-message <text> save a wake schedule
3678
+ bound to the new instance's home (docs/schedules.md)
3533
3679
  oats session start --home <absolute-home> start a STOPPED instance again in its existing home
3534
3680
  [--model <m>] [--json] (same identity, worktree, notes and launch env; no
3535
3681
  spawn hooks); --model replaces the recorded model
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
@@ -0,0 +1,25 @@
1
+ # OATS v0.22.10
2
+
3
+ One Desktop defect an operator meets in the sidebar: the instance actions
4
+ control was a native select that did not behave like the rest of the app.
5
+
6
+ ## Sidebar instance actions: an app-styled button and popover
7
+
8
+ Each instance row's actions control (Harvest knowledge, Retire instance) was
9
+ a native `<select>` labelled with an ellipsis. It rendered with the
10
+ platform's own look, opened as a native menu, and its keyboard and focus
11
+ behaviour did not match the app's other controls. It is now an app-styled
12
+ button that opens a popover. The menu opens from the keyboard or pointer and
13
+ moves focus into its items. Escape and choosing an action close it and return
14
+ focus to the button; clicking outside also dismisses it.
15
+
16
+ Safety is unchanged. The per-instance pending guard still holds: while a
17
+ harvest or a retirement is in flight for an instance, every control for that
18
+ instance stays disabled until the operation reports, so a second click
19
+ cannot start a duplicate operation, and a roster refresh while an operation
20
+ is pending re-renders the control still disabled. A remote instance with no
21
+ saved route on this machine still has its actions disabled with the same
22
+ explanation.
23
+
24
+ Nothing else changes: no kernel, session start, start dialog or record
25
+ schema change. Tag v0.22.9 and everything it shipped stand.
@@ -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.
@@ -0,0 +1,143 @@
1
+ # Schedules
2
+
3
+ A schedule launches an agent, runs an oats command, or wakes an existing
4
+ instance on a cron. Definitions belong to a scope, the team workspace (the
5
+ config level that declares the team, else the outermost `oats-config.yaml`
6
+ level), and are committable; every `oats schedule` command run anywhere
7
+ inside that scope, including from an instance home, reads and writes the
8
+ same file. Execution belongs to the host that holds the scope, so a
9
+ schedule on a registered server keeps running while your laptop sleeps.
10
+
11
+ There is no daemon. One host timer (a launchd user agent on macOS, a systemd
12
+ user timer on Linux) runs `oats schedule tick --host` once a minute; the tick
13
+ is a short-lived process that evaluates only the current minute, launches
14
+ what is due through the same `spawn`, `session start` and `session input`
15
+ paths you use by hand, records what it observed, and exits. Minutes missed
16
+ while the machine slept are skipped, never replayed. There are no retries
17
+ and no queue.
18
+
19
+ ## Files
20
+
21
+ - `<workspace>/oats-schedules.json` — the definitions (`{version: 1, jobs:
22
+ {<id>: ...}}`). Commit it if you want the schedule shared with the team.
23
+ - `<workspace>/.agents/schedules/state.json` — last attempted minute and
24
+ last run per job (gitignored), plus one lock directory per running job.
25
+ - `~/.oats/schedules/registry.json` — the host registry: which scopes the
26
+ host ticks, `maxConcurrent` (default 1) and the tick interval. One host
27
+ lock serializes ticks, run-now, reconcile and remove; it is never reclaimed
28
+ by another process: a lock whose owner is unreadable or gone is reported
29
+ with the directory to remove, and the holder removes its own lock on exit
30
+ and on SIGINT/SIGTERM. Definition edits take a short per-scope lock.
31
+
32
+ ## Kinds
33
+
34
+ - **spawn** `{id, enabled, cron, tz, kind: "spawn", agent, agentsRoot?,
35
+ repo?, backend?, purpose?, task, runtime?, model?, yolo?, wake?}` — every
36
+ due minute launches one disposable instance of `agent` with the same
37
+ options `oats spawn` takes. `agentsRoot` names the exact agents root that
38
+ holds the soul (it must lie inside the workspace and defaults to the
39
+ workspace's own root); it is what tells same-named souls in different
40
+ member repositories apart. `repo` is the work repository, as `--repo`. The task gets a trailing schedule block naming the job and the
41
+ minute and ending with `oats retire --self`. An optional `wake` object
42
+ (`{cron, tz, message}`) attaches a wake schedule to each launched instance;
43
+ nothing is attached unless you ask.
44
+ - **command** `{id, enabled, cron, tz, kind: "command", cwd, argv}` — runs
45
+ an oats-only argv (`argv[0]` is `oats`, no shell) in `cwd`, which must be
46
+ inside the workspace. The runner parses the command's envelope and tracks
47
+ any instance it names, so `["oats", "okf", "harvest"]` run in a source
48
+ instance's home is followed until the harvester it spawned is gone.
49
+ Command return is not task completion.
50
+ - **wake** `{id, enabled, cron, tz, kind: "wake", home, message}` — every
51
+ due minute inspects the instance at `home` through its session receipts.
52
+ Running: `message` is delivered once with `session input`. Not running
53
+ (absent, dead pane or fallback shell): the same home is started with
54
+ `session start` and the message becomes the job's one pending delivery,
55
+ completed on a later tick, any tick, as soon as the session is active; the
56
+ home is started again only at due minutes, never every minute, so a
57
+ harness that keeps exiting is not restarted in a loop. A job holds at most
58
+ one pending delivery: a due minute while one is pending adds nothing.
59
+ Unobservable or still starting: skipped with the reason, delivery kept
60
+ pending. Whether a running harness is busy cannot be seen from the
61
+ terminal: delivery is terminal input (bracketed paste plus Enter), never an
62
+ interrupt, never Ctrl-C, never into a stopped or starting shell. Word wake
63
+ messages so that receiving one again is harmless.
64
+
65
+ `cron` has five fields (minute hour day month weekday) and `tz` is a
66
+ required IANA zone; both are evaluated by the croner library. `--wake-every
67
+ N` at spawn time means `*/N * * * *`: every 7 fires at :00, :07, ... :56 and
68
+ then :00 again, so 1, 5, 10, 15 and 30 give an even cadence.
69
+
70
+ ## Commands
71
+
72
+ ```sh
73
+ oats schedule add <id> --file spec.json --dir <workspace> --json
74
+ oats schedule update <id> --file spec.json
75
+ oats schedule list | show <id> | enable <id> | disable <id> | remove <id>
76
+ oats schedule run <id> # now, under the same lock and bound
77
+ oats schedule tick --dry-run # what would run this minute, launching nothing
78
+ oats schedule reconcile <id> [--clear] # resolve an attempt whose result was never recorded
79
+ oats schedule host install # register this scope and install the ONE host timer (idempotent while active)
80
+ oats schedule host status | uninstall
81
+ oats spawn <agent> ... --wake-every 15 --wake-message "Anything new?" # or --wake-file spec.json
82
+ ```
83
+
84
+ Every subcommand takes `--server <id>` instead of `--dir`: it then runs on
85
+ that host, in its registered workspace, because schedules are host-owned.
86
+
87
+ `list --json` answers `{schedules: [{id, ...definition, nextRun, lastRun,
88
+ running}], scheduler: {installed, active, lastTick, maxConcurrent, ...}}`.
89
+ `active` is what the OS reports about the timer, not whether a file exists.
90
+
91
+ ## What a run reports
92
+
93
+ `launched` (spawn or command returned), `active` (the instance is running;
94
+ a home whose retirement is pending still counts, its runtime may be alive),
95
+ `ended` (its home is gone), `stopped` (home present, nothing running: needs
96
+ attention, never removed for you), `launch-failed`, `unknown`, and for wake
97
+ jobs `delivered`, `started` or `skipped`. The kernel never claims a task
98
+ succeeded.
99
+
100
+ `unknown` means the launch's side effects are unconfirmed: a command timed
101
+ out or answered no envelope, an envelope named an instance the roster
102
+ cannot place, or an attempt was never recorded. The job keeps its slot and
103
+ is skipped until `oats schedule reconcile <id>`. Reconcile adopts only an
104
+ attributable receipt: a spawn job's instance is named deterministically for
105
+ its minute, a command job's only by the instance its answer named. Nothing
106
+ is inferred from file times. A command whose answer named nothing stays
107
+ unknown; check the roster and the host by hand, then
108
+ `oats schedule reconcile <id> --clear` records launch-failed and frees the
109
+ slot (or `remove --force` forgets the job).
110
+
111
+ A wake job that starts a stopped home holds a launch slot while that
112
+ runtime is starting, active, retiring or unobservable, and releases it when
113
+ the runtime is proven stopped (the session start receipt's exit marker for
114
+ that launch, or a home that no longer has a session) or the home is gone.
115
+ A persistent home that outlives its process does not keep a slot. Delivering
116
+ a message to a home that is already running takes no slot. The host tick
117
+ observes every registered scope first, then admits due jobs in one
118
+ host-wide order, least recently launched first (only an actual runtime
119
+ launch counts; a skipped or pending job keeps its place at the front), so
120
+ one frequent job in one scope cannot keep the only slot forever. An invalid
121
+ or malformed definition is reported on that job and the rest of the tick
122
+ continues.
123
+
124
+ `disable` never stops anything. `update` never touches a running instance,
125
+ and while a job holds a slot or has an unresolved attempt what its run is
126
+ tracked or reconciled by (kind, agent, agentsRoot, repo, purpose, home, cwd,
127
+ argv) cannot change; cron, tz, task, message, runtime, model and enabled
128
+ can. A cold wake persists its slot before the session start runs and keeps
129
+ it on any start exception, whatever its code (the kernel can refuse while
130
+ recording, after the session exists); the next observation releases it once
131
+ the runtime is proven stopped or absent, one tick at worst.
132
+ `remove` refuses while the job's instance is still tracked (`--force`
133
+ forgets the job without stopping anything). Retiring an instance removes the
134
+ wake jobs bound to its home; a wake whose home is gone otherwise stays
135
+ listed with its skipped reason.
136
+
137
+ ## Wake at spawn
138
+
139
+ `oats spawn ... --wake-file <private JSON {cron, tz, message, enabled}>`
140
+ saves a wake job `wake-<instance>` bound to the new home after the spawn
141
+ succeeded. If the spawn succeeds but the save fails, the spawn result still
142
+ carries the full instance receipt, plus `wakeScheduleError` and a warning;
143
+ the instance is neither hidden nor spawned again.