@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.
@@ -0,0 +1,44 @@
1
+ # OATS v0.22.13
2
+
3
+ Terminal essentials for the Desktop and one kernel operation behind them:
4
+ attachments reach the agent as a path, never as typed input.
5
+
6
+ ## Attachments (kernel)
7
+
8
+ `oats session upload --file <local> --home <abs>` copies a file into the
9
+ instance home's private `.oats-attachments/` directory (0700, file 0600,
10
+ `name-2` when the name is taken) and answers `{path, bytes, sha256}`. With
11
+ `--server <id> --instance <name>` (or `--home`), the same saved route as
12
+ `session attach` is resolved, the remote must advertise the new
13
+ `session-upload` feature, and the bytes travel on ssh stdin into
14
+ `oats session receive --home <abs> --name <file>` on the execution host,
15
+ which reads stdin event-driven, enforces the 64 MiB kernel bound before
16
+ writing anything, allocates the destination exclusively so simultaneous
17
+ uploads never overwrite each other, refuses a symlinked or redirected
18
+ attachments directory, and reports the path only after the bytes are
19
+ synced. The local side refuses the answer unless size and sha256 equal the
20
+ local file's. Each side buffers the whole file up to the bound; a remote
21
+ file is never a local path over SSH. Upload never sends terminal input.
22
+ `oats version --json` lists `session-upload` in `remote` and `features`.
23
+ The CLI's temporary tmux viewer session hides its status line; the agents'
24
+ session and the operator's tmux settings are untouched.
25
+
26
+ ## Desktop
27
+
28
+ Drop files or paste clipboard images onto an agent terminal: the paths are
29
+ inserted into the draft with bracketed paste and no Enter, local files in
30
+ place, clipboard images saved privately under the app's data directory, and
31
+ remote terminals uploaded through the installed CLI first (both CLIs must
32
+ advertise `session-upload`; a failed transfer leaves the draft unchanged and
33
+ never inserts a local path). Up to 16 files and 25 MB per drop. The viewer's
34
+ tmux status bar is hidden. Ctrl+Tab stays tab navigation inside terminals
35
+ on macOS while other Ctrl chords stay with the program. An already-open
36
+ terminal fills an empty split without a second attach; separators resize by
37
+ pointer or keyboard. Guide: docs/desktop.md, "Attach files and screenshots".
38
+
39
+ ## Design
40
+
41
+ docs/design/2026-09-07-architecture-reassessment.md records the direction:
42
+ keep the package and capability-layer machinery, fix the places where the
43
+ CLI and GUI bypass it, one session contract with both tmux and Herdr
44
+ adapters until Herdr is qualified, and the Desktop correction sequence.
@@ -0,0 +1,48 @@
1
+ # OATS v0.22.14
2
+
3
+ The same reviewed content as the unpublished v0.22.13 tag, whose hosted
4
+ run failed in one Desktop test pin before anything was published; the
5
+ test now checks the behaviour instead of the source text.
6
+
7
+ Terminal essentials for the Desktop and one kernel operation behind them:
8
+ attachments reach the agent as a path, never as typed input.
9
+
10
+ ## Attachments (kernel)
11
+
12
+ `oats session upload --file <local> --home <abs>` copies a file into the
13
+ instance home's private `.oats-attachments/` directory (0700, file 0600,
14
+ `name-2` when the name is taken) and answers `{path, bytes, sha256}`. With
15
+ `--server <id> --instance <name>` (or `--home`), the same saved route as
16
+ `session attach` is resolved, the remote must advertise the new
17
+ `session-upload` feature, and the bytes travel on ssh stdin into
18
+ `oats session receive --home <abs> --name <file>` on the execution host,
19
+ which reads stdin event-driven, enforces the 64 MiB kernel bound before
20
+ writing anything, allocates the destination exclusively so simultaneous
21
+ uploads never overwrite each other, refuses a symlinked or redirected
22
+ attachments directory, and reports the path only after the bytes are
23
+ synced. The local side refuses the answer unless size and sha256 equal the
24
+ local file's. Each side buffers the whole file up to the bound; a remote
25
+ file is never a local path over SSH. Upload never sends terminal input.
26
+ `oats version --json` lists `session-upload` in `remote` and `features`.
27
+ The CLI's temporary tmux viewer session hides its status line; the agents'
28
+ session and the operator's tmux settings are untouched.
29
+
30
+ ## Desktop
31
+
32
+ Drop files or paste clipboard images onto an agent terminal: the paths are
33
+ inserted into the draft with bracketed paste and no Enter, local files in
34
+ place, clipboard images saved privately under the app's data directory, and
35
+ remote terminals uploaded through the installed CLI first (both CLIs must
36
+ advertise `session-upload`; a failed transfer leaves the draft unchanged and
37
+ never inserts a local path). Up to 16 files and 25 MB per drop. The viewer's
38
+ tmux status bar is hidden. Ctrl+Tab stays tab navigation inside terminals
39
+ on macOS while other Ctrl chords stay with the program. An already-open
40
+ terminal fills an empty split without a second attach; separators resize by
41
+ pointer or keyboard. Guide: docs/desktop.md, "Attach files and screenshots".
42
+
43
+ ## Design
44
+
45
+ docs/design/2026-09-07-architecture-reassessment.md records the direction:
46
+ keep the package and capability-layer machinery, fix the places where the
47
+ CLI and GUI bypass it, one session contract with both tmux and Herdr
48
+ adapters until Herdr is qualified, and the Desktop correction sequence.
@@ -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.
@@ -0,0 +1,164 @@
1
+ /** Instance attachments: bytes a viewer drops or pastes for an agent, kept as
2
+ * private files INSIDE the instance home so the agent can read them by path.
3
+ * Upload never touches the terminal; the caller pastes the returned path.
4
+ * Remote uploads stream through the same ssh transport as every routed
5
+ * command, into `oats session receive` on the execution host. */
6
+ import { createHash } from "node:crypto";
7
+ import { closeSync, existsSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readSync, rmSync, statSync, writeSync, realpathSync } from "node:fs";
8
+ import { basename, extname, isAbsolute, join, resolve, sep } from "node:path";
9
+ import { checkRemote, resolveRoute, runRemote, serverError } from "./servers.mjs";
10
+
11
+ export const ATTACHMENTS_DIRNAME = ".oats-attachments";
12
+ /** Kernel bound per file; a GUI imposes its own stricter interaction bound. */
13
+ export const MAX_ATTACHMENT_BYTES = 64 * 1024 * 1024;
14
+ /** The kernel version whose probe first advertises session-upload. */
15
+ export const SESSION_UPLOAD_REMOTE_VERSION = "0.22.13";
16
+
17
+ const err = (code, message, extra) => Object.assign(new Error(message), { code, ...(extra || {}) });
18
+
19
+ /** A file name as it will exist in the attachments directory: one path
20
+ * segment, printable, never a dot name, at most 200 bytes. */
21
+ export function attachmentName(name) {
22
+ if (typeof name !== "string" || !name.trim()) throw err("E_BAD_ARGS", "attachment name must be a non-empty file name");
23
+ if (name.includes("/") || name.includes("\\") || name.includes("\0")) throw err("E_BAD_ARGS", "attachment name must be a single path segment without slashes or NUL");
24
+ if (/[\x00-\x1f\x7f]/.test(name)) throw err("E_BAD_ARGS", "attachment name must not contain control characters");
25
+ if (name === "." || name === "..") throw err("E_BAD_ARGS", "attachment name may not be a dot name");
26
+ // Deliberate: a name starting with a dash would read as an option on the
27
+ // remote command line; the caller renames the file (screenshots never
28
+ // start with one).
29
+ if (name.startsWith("-")) throw err("E_BAD_ARGS", `attachment name ${JSON.stringify(name)} starts with a dash; rename the file before attaching it`);
30
+ if (Buffer.byteLength(name) > 200) throw err("E_BAD_ARGS", "attachment name is longer than 200 bytes");
31
+ return name;
32
+ }
33
+
34
+ export function sha256Hex(bytes) { return createHash("sha256").update(bytes).digest("hex"); }
35
+
36
+ /** Read a REGULAR file descriptor to its end, refusing once more than
37
+ * `maxBytes` arrive. Regular files never answer EAGAIN; pipes must use
38
+ * readStreamBounded, which waits for data instead of spinning. */
39
+ export function readBounded(fd, maxBytes = MAX_ATTACHMENT_BYTES) {
40
+ const chunks = [];
41
+ let total = 0;
42
+ const buf = Buffer.allocUnsafe(1024 * 1024);
43
+ for (;;) {
44
+ const n = readSync(fd, buf, 0, buf.length, null);
45
+ if (n === 0) break;
46
+ total += n;
47
+ if (total > maxBytes) throw err("E_UPLOAD_TOO_LARGE", `attachment exceeds the kernel bound of ${maxBytes} bytes`);
48
+ chunks.push(Buffer.from(buf.subarray(0, n)));
49
+ }
50
+ return Buffer.concat(chunks, total);
51
+ }
52
+
53
+ /** Collect a readable stream (stdin from ssh) into memory, bounded: the
54
+ * read is event-driven, so a slow or stalled sender costs no CPU, and the
55
+ * bound is checked as chunks arrive, before anything is written. The whole
56
+ * file is buffered on both sides (up to the bound); this is not end-to-end
57
+ * streaming. */
58
+ export async function readStreamBounded(stream, maxBytes = MAX_ATTACHMENT_BYTES) {
59
+ const chunks = [];
60
+ let total = 0;
61
+ for await (const chunk of stream) {
62
+ const b = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
63
+ total += b.length;
64
+ if (total > maxBytes) { stream.destroy?.(); throw err("E_UPLOAD_TOO_LARGE", `attachment exceeds the kernel bound of ${maxBytes} bytes`); }
65
+ chunks.push(b);
66
+ }
67
+ return Buffer.concat(chunks, total);
68
+ }
69
+
70
+ function instanceHomeOf(home) {
71
+ if (typeof home !== "string" || !isAbsolute(home)) throw err("E_BAD_ARGS", "attachments need an absolute instance home");
72
+ if (!existsSync(join(home, "instance.json"))) throw err("E_SESSION_UNKNOWN", `${home} is not an OATS instance home (no instance.json); nothing was written`);
73
+ return realpathSync(home);
74
+ }
75
+
76
+ /** The attachments directory must be a real directory inside the home: a
77
+ * symlink planted there (or a file) must not turn a receive into a write
78
+ * somewhere else. Created 0700 when absent. */
79
+ function attachmentsDir(realHome) {
80
+ const dir = join(realHome, ATTACHMENTS_DIRNAME);
81
+ let st;
82
+ try { st = lstatSync(dir); }
83
+ catch (e) {
84
+ if (e.code !== "ENOENT") throw e;
85
+ // Two first uploads may both see ENOENT: the loser's mkdir answers
86
+ // EEXIST and it validates what the winner (or a planted entry) put there.
87
+ try { mkdirSync(dir, { mode: 0o700 }); } catch (m) { if (m.code !== "EEXIST") throw m; }
88
+ st = lstatSync(dir);
89
+ }
90
+ if (st.isSymbolicLink() || !st.isDirectory()) throw err("E_UPLOAD_FAILED", `${dir} is not a real directory inside the instance home; nothing was written`);
91
+ const real = realpathSync(dir);
92
+ if (real !== dir && !real.startsWith(realHome + sep)) throw err("E_UPLOAD_FAILED", `${dir} resolves outside the instance home; nothing was written`);
93
+ return dir;
94
+ }
95
+
96
+ /** Store `bytes` as a private file under <home>/.oats-attachments/<name>,
97
+ * taking name-2, name-3 ... when the name is taken. The destination is
98
+ * allocated exclusively (O_CREAT|O_EXCL, 0600): two simultaneous uploads
99
+ * of the same name get two files and neither clobbers the other, and a
100
+ * symlink planted under a candidate name is skipped, never followed. The
101
+ * path is reported only after the bytes are written and synced. */
102
+ export function receiveAttachment(home, name, bytes, { maxBytes = MAX_ATTACHMENT_BYTES } = {}) {
103
+ const realHome = instanceHomeOf(home);
104
+ const safe = attachmentName(name);
105
+ if (!Buffer.isBuffer(bytes)) throw err("E_BAD_ARGS", "attachment bytes must be a Buffer");
106
+ if (bytes.length > maxBytes) throw err("E_UPLOAD_TOO_LARGE", `attachment exceeds the kernel bound of ${maxBytes} bytes`);
107
+ const dir = attachmentsDir(realHome);
108
+ const ext = extname(safe), stem = safe.slice(0, safe.length - ext.length);
109
+ for (let i = 1; i <= 10000; i++) {
110
+ const path = i === 1 ? join(dir, safe) : join(dir, `${stem}-${i}${ext}`);
111
+ let fd;
112
+ try { fd = openSync(path, "wx", 0o600); }
113
+ catch (e) { if (e.code === "EEXIST") continue; throw e; }
114
+ try {
115
+ let off = 0;
116
+ while (off < bytes.length) off += writeSync(fd, bytes, off, bytes.length - off);
117
+ fsyncSync(fd);
118
+ } catch (e) { closeSync(fd); rmSync(path, { force: true }); throw e; }
119
+ closeSync(fd);
120
+ return { path, bytes: bytes.length, sha256: sha256Hex(bytes) };
121
+ }
122
+ throw err("E_UPLOAD_FAILED", `no free name for ${safe} under ${dir}`);
123
+ }
124
+
125
+ function readLocalFile(file, maxBytes) {
126
+ if (typeof file !== "string" || !file) throw err("E_BAD_ARGS", "--file needs a path");
127
+ const abs = resolve(file);
128
+ let st;
129
+ try { st = statSync(abs); } catch { throw err("E_BAD_ARGS", `no such file: ${abs}`); }
130
+ if (!st.isFile()) throw err("E_BAD_ARGS", `${abs} is not a regular file`);
131
+ if (st.size > maxBytes) throw err("E_UPLOAD_TOO_LARGE", `${abs} is ${st.size} bytes; the kernel bound is ${maxBytes}`);
132
+ const fd = openSync(abs, "r");
133
+ try { if (fstatSync(fd).size !== st.size) throw err("E_UPLOAD_FAILED", `${abs} changed while being read`); return { abs, bytes: readBounded(fd, maxBytes) }; }
134
+ finally { closeSync(fd); }
135
+ }
136
+
137
+ /** Upload a local file into an instance's attachments: locally by home, or
138
+ * on the execution host of a registered server through its saved route
139
+ * (the same resolution as session attach). The remote answer's sha256 and
140
+ * size must equal the local file's before the path is reported. */
141
+ export function uploadAttachment({ file, home, server, instance }, io = {}) {
142
+ const maxBytes = io.maxBytes || MAX_ATTACHMENT_BYTES;
143
+ const { abs, bytes } = readLocalFile(file, maxBytes);
144
+ const name = basename(abs);
145
+ const sha256 = sha256Hex(bytes);
146
+ if (!server) {
147
+ if (!home) throw err("E_BAD_ARGS", "session upload needs --home </absolute/instance> or --server <id> with --instance <name> or --home");
148
+ return { ...receiveAttachment(home, name, bytes, { maxBytes }), name, home: realpathSync(home), source: abs };
149
+ }
150
+ const route = resolveRoute(server, { instance, home }, "session upload");
151
+ const remote = checkRemote(route.target, io);
152
+ // Both lists must carry the token; a probe without the arrays is an old kernel.
153
+ const advertises = (list) => Array.isArray(list) && list.includes("session-upload");
154
+ if (!advertises(remote.remote) || !advertises(remote.features)) {
155
+ throw serverError("E_REMOTE_INCOMPATIBLE", `remote oats ${remote.version} at ${route.target.sshHost} does not advertise session-upload (kernels from ${SESSION_UPLOAD_REMOTE_VERSION} do); upgrade it there; nothing was sent`);
156
+ }
157
+ const { envelope, stderr } = runRemote(route.target, ["session", "receive", "--home", route.home, "--name", name, "--json"], { ...io, input: bytes, timeoutMs: io.timeoutMs || 600000 });
158
+ if (!envelope.ok) throw serverError(envelope.error?.code || "E_UPLOAD_FAILED", `${server}: ${envelope.error?.message || "receive failed"}`);
159
+ const r = envelope.result || {};
160
+ if (r.sha256 !== sha256 || r.bytes !== bytes.length || typeof r.path !== "string" || !r.path.startsWith("/")) {
161
+ throw serverError("E_UPLOAD_FAILED", `${server} stored ${r.bytes ?? "?"} bytes with sha256 ${r.sha256 ?? "?"} at ${r.path || "?"}, but the local file is ${bytes.length} bytes with sha256 ${sha256}; the remote file is left for inspection`);
162
+ }
163
+ return { path: r.path, bytes: r.bytes, sha256: r.sha256, name, home: route.home, server, instance: instance || route.snapshot?.instance, source: abs, ...(stderr?.trim() ? { stderr: stderr.trim() } : {}) };
164
+ }
@@ -0,0 +1,150 @@
1
+ /** The ONE host timer that runs `oats schedule tick --host` once a minute:
2
+ * a launchd user agent on macOS, a systemd user timer on Linux. Nothing is
3
+ * installed unless `oats schedule host install` is run explicitly, and
4
+ * status reports what the OS says is loaded, never what a file implies. */
5
+ import { execFileSync } from "node:child_process";
6
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
7
+ import { homedir, platform, userInfo } from "node:os";
8
+ import { dirname, join } from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+ import { hostScheduleDir, scheduleError } from "./schedule.mjs";
11
+
12
+ export const LAUNCHD_LABEL = "ai.oats.schedule-tick";
13
+ export const SYSTEMD_UNIT = "oats-schedule-tick";
14
+ const OATS_BIN = join(dirname(fileURLToPath(import.meta.url)), "..", "bin", "oats.mjs");
15
+
16
+ function escapeXml(s) { return String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;"); }
17
+
18
+ /** launchd and systemd start units with a short PATH that has no Homebrew
19
+ * tmux and no installed harness. The unit therefore carries an explicit
20
+ * PATH: the installer's own PATH (the operator's tool environment) plus
21
+ * the node running the installer and the usual prefixes. */
22
+ export function hostPath({ node = process.execPath, envPath = process.env.PATH || "" } = {}) {
23
+ const seen = new Set();
24
+ const out = [];
25
+ for (const p of [dirname(node), ...envPath.split(":"), "/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"]) {
26
+ if (p && !seen.has(p)) { seen.add(p); out.push(p); }
27
+ }
28
+ return out.join(":");
29
+ }
30
+
31
+ export function renderLaunchdPlist({ node = process.execPath, oatsBin = OATS_BIN, logDir = join(hostScheduleDir(), "log"), intervalSec = 60, oatsHomeDir = process.env.OATS_HOME_DIR, path = hostPath({ node }) } = {}) {
32
+ const env = `\n <key>EnvironmentVariables</key>\n <dict>\n <key>PATH</key><string>${escapeXml(path)}</string>${oatsHomeDir ? `\n <key>OATS_HOME_DIR</key><string>${escapeXml(oatsHomeDir)}</string>` : ""}\n </dict>`;
33
+ return `<?xml version="1.0" encoding="UTF-8"?>
34
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
35
+ <plist version="1.0">
36
+ <dict>
37
+ <key>Label</key><string>${LAUNCHD_LABEL}</string>
38
+ <key>ProgramArguments</key>
39
+ <array>
40
+ <string>${escapeXml(node)}</string>
41
+ <string>${escapeXml(oatsBin)}</string>
42
+ <string>schedule</string>
43
+ <string>tick</string>
44
+ <string>--host</string>
45
+ <string>--json</string>
46
+ </array>
47
+ <key>StartInterval</key><integer>${intervalSec}</integer>
48
+ <key>RunAtLoad</key><false/>
49
+ <key>ProcessType</key><string>Background</string>
50
+ <key>StandardOutPath</key><string>${escapeXml(join(logDir, "tick.log"))}</string>
51
+ <key>StandardErrorPath</key><string>${escapeXml(join(logDir, "tick.err"))}</string>${env}
52
+ </dict>
53
+ </plist>
54
+ `;
55
+ }
56
+ /** systemd unit values: `%` is a specifier escape (`%%`), and a quoted
57
+ * string protects spaces; backslashes and double quotes are escaped. */
58
+ export function systemdQuote(value) {
59
+ return `"${String(value).replace(/%/g, "%%").replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
60
+ }
61
+ export function renderSystemdUnits({ node = process.execPath, oatsBin = OATS_BIN, intervalSec = 60, oatsHomeDir = process.env.OATS_HOME_DIR, path = hostPath({ node }) } = {}) {
62
+ const env = `Environment=${systemdQuote(`PATH=${path}`)}\n${oatsHomeDir ? `Environment=${systemdQuote(`OATS_HOME_DIR=${oatsHomeDir}`)}\n` : ""}`;
63
+ return {
64
+ service: `[Unit]\nDescription=OATS schedule tick\n\n[Service]\nType=oneshot\n${env}ExecStart=${systemdQuote(node)} ${systemdQuote(oatsBin)} schedule tick --host --json\n`,
65
+ timer: `[Unit]\nDescription=OATS schedule tick every ${intervalSec}s\n\n[Timer]\nOnBootSec=${intervalSec}\nOnUnitActiveSec=${intervalSec}\nAccuracySec=5\nUnit=${SYSTEMD_UNIT}.service\n\n[Install]\nWantedBy=timers.target\n`,
66
+ };
67
+ }
68
+
69
+ /** `unitDir` overrides the OS location (tests never touch a live timer's files). */
70
+ export function hostUnitPaths(os = platform(), { unitDir } = {}) {
71
+ if (os === "darwin") return { kind: "launchd", plist: join(unitDir || join(homedir(), "Library", "LaunchAgents"), `${LAUNCHD_LABEL}.plist`) };
72
+ if (os === "linux") { const dir = unitDir || join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "systemd", "user"); return { kind: "systemd", service: join(dir, `${SYSTEMD_UNIT}.service`), timer: join(dir, `${SYSTEMD_UNIT}.timer`) }; }
73
+ return { kind: "unsupported" };
74
+ }
75
+
76
+ function run(exec, argv) {
77
+ try { return { ok: true, out: (exec || execFileSync)(argv[0], argv.slice(1), { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 20000 }) }; }
78
+ catch (e) { return { ok: false, out: String(e.stdout || ""), err: String(e.stderr || e.message || "").trim() }; }
79
+ }
80
+
81
+ /** installed = the unit file exists; active = the OS reports it loaded/active. */
82
+ export function hostUnitStatus({ exec, os = platform(), unitDir } = {}) {
83
+ const p = hostUnitPaths(os, { unitDir });
84
+ if (p.kind === "launchd") {
85
+ const installed = existsSync(p.plist);
86
+ const r = run(exec, ["launchctl", "print", `gui/${userInfo().uid}/${LAUNCHD_LABEL}`]);
87
+ return { kind: p.kind, unit: p.plist, installed, active: r.ok };
88
+ }
89
+ if (p.kind === "systemd") {
90
+ const installed = existsSync(p.timer) && existsSync(p.service);
91
+ const r = run(exec, ["systemctl", "--user", "is-active", `${SYSTEMD_UNIT}.timer`]);
92
+ return { kind: p.kind, unit: p.timer, installed, active: r.ok && r.out.trim() === "active" };
93
+ }
94
+ return { kind: "unsupported", installed: false, active: false };
95
+ }
96
+
97
+ export function installHostUnit({ exec, os = platform(), intervalSec = 60, unitDir } = {}) {
98
+ const p = hostUnitPaths(os, { unitDir });
99
+ mkdirSync(join(hostScheduleDir(), "log"), { recursive: true });
100
+ if (p.kind === "launchd") {
101
+ mkdirSync(dirname(p.plist), { recursive: true });
102
+ const status = hostUnitStatus({ exec, os, unitDir });
103
+ const rendered = renderLaunchdPlist({ intervalSec });
104
+ // Idempotent: registering another scope while the same timer is active
105
+ // must not unload an in-flight tick just to rewrite identical bytes.
106
+ if (status.active && existsSync(p.plist) && readFileSync(p.plist, "utf8") === rendered) return status;
107
+ if (status.active) { const r = run(exec, ["launchctl", "bootout", `gui/${userInfo().uid}/${LAUNCHD_LABEL}`]); if (!r.ok) throw scheduleError("E_SCHEDULE_HOST", `launchctl bootout failed before reinstall: ${r.err}`); }
108
+ writeFileSync(p.plist, rendered);
109
+ const r = run(exec, ["launchctl", "bootstrap", `gui/${userInfo().uid}`, p.plist]);
110
+ if (!r.ok) throw scheduleError("E_SCHEDULE_HOST", `launchctl bootstrap failed: ${r.err}`);
111
+ return hostUnitStatus({ exec, os, unitDir });
112
+ }
113
+ if (p.kind === "systemd") {
114
+ mkdirSync(dirname(p.timer), { recursive: true });
115
+ const units = renderSystemdUnits({ intervalSec });
116
+ const status = hostUnitStatus({ exec, os, unitDir });
117
+ if (status.active && existsSync(p.service) && existsSync(p.timer) && readFileSync(p.service, "utf8") === units.service && readFileSync(p.timer, "utf8") === units.timer) return status;
118
+ writeFileSync(p.service, units.service); writeFileSync(p.timer, units.timer);
119
+ for (const argv of [["systemctl", "--user", "daemon-reload"], ["systemctl", "--user", "enable", "--now", `${SYSTEMD_UNIT}.timer`]]) { const r = run(exec, argv); if (!r.ok) throw scheduleError("E_SCHEDULE_HOST", `${argv.join(" ")} failed: ${r.err}`); }
120
+ return hostUnitStatus({ exec, os, unitDir });
121
+ }
122
+ throw scheduleError("E_SCHEDULE_HOST", `no host timer support on ${os}`);
123
+ }
124
+
125
+ /** Uninstall reports the OS's answer: a unit that stays loaded after the
126
+ * OS refused to unload it is an error, not a removed file. */
127
+ export function uninstallHostUnit({ exec, os = platform(), unitDir } = {}) {
128
+ const p = hostUnitPaths(os, { unitDir });
129
+ if (p.kind === "launchd") {
130
+ const before = hostUnitStatus({ exec, os, unitDir });
131
+ if (before.active) {
132
+ const r = run(exec, ["launchctl", "bootout", `gui/${userInfo().uid}/${LAUNCHD_LABEL}`]);
133
+ if (!r.ok || hostUnitStatus({ exec, os, unitDir }).active) throw scheduleError("E_SCHEDULE_HOST", `launchctl bootout did not unload ${LAUNCHD_LABEL}: ${r.err || "still loaded"}; the unit file is kept`);
134
+ }
135
+ rmSync(p.plist, { force: true });
136
+ return hostUnitStatus({ exec, os, unitDir });
137
+ }
138
+ if (p.kind === "systemd") {
139
+ const before = hostUnitStatus({ exec, os, unitDir });
140
+ if (before.active) {
141
+ const r = run(exec, ["systemctl", "--user", "disable", "--now", `${SYSTEMD_UNIT}.timer`]);
142
+ if (!r.ok || hostUnitStatus({ exec, os, unitDir }).active) throw scheduleError("E_SCHEDULE_HOST", `systemctl --user disable --now ${SYSTEMD_UNIT}.timer failed: ${r.err || "still active"}; the unit files are kept`);
143
+ }
144
+ rmSync(p.timer, { force: true }); rmSync(p.service, { force: true });
145
+ const r = run(exec, ["systemctl", "--user", "daemon-reload"]);
146
+ if (!r.ok) throw scheduleError("E_SCHEDULE_HOST", `systemctl --user daemon-reload failed: ${r.err}`);
147
+ return hostUnitStatus({ exec, os, unitDir });
148
+ }
149
+ throw scheduleError("E_SCHEDULE_HOST", `no host timer support on ${os}`);
150
+ }