@awebai/oats 0.22.1 → 0.22.3

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,623 @@
1
+ /**
2
+ * Server registry and remote CLI routing (docs/execution-targets.md).
3
+ *
4
+ * A registered server is another machine that runs its own installed OATS
5
+ * over an OpenSSH host alias. Registrations live in the operator's machine
6
+ * configuration (~/.oats/servers.json, never a repository scope) and hold no
7
+ * key material: SSH owns key selection, host verification and authentication.
8
+ *
9
+ * Routing is deliberately thin: a remote lifecycle call is the remote
10
+ * installed OATS CLI run over ssh with argument-safe quoting and the same
11
+ * JSON envelope as a local call. The remote kernel is the authority over its
12
+ * own homes, receipts and baselines; what the local side keeps is a ROUTE
13
+ * SNAPSHOT per remote instance, taken at spawn, so that inspect and retire
14
+ * work from the snapshot alone and a registry entry edited or deleted later
15
+ * can never orphan a remote home. Nothing here implements SSH itself, and
16
+ * nothing here runs Git against a remote path.
17
+ */
18
+
19
+ import { execFileSync } from "node:child_process";
20
+ import { createHash } from "node:crypto";
21
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
22
+ import { homedir } from "node:os";
23
+ import { join, resolve } from "node:path";
24
+
25
+ const OATS_HOME_DIR = () => process.env.OATS_HOME_DIR || join(homedir(), ".oats");
26
+ export const SERVERS_FILE = () => join(OATS_HOME_DIR(), "servers.json");
27
+ export const REMOTE_SNAPSHOT_DIR = () => join(OATS_HOME_DIR(), "remote");
28
+
29
+ /** Every ssh invocation is non-interactive: a host that needs a password or a
30
+ * first-time key confirmation fails fast with ssh's own message, instead of
31
+ * a lifecycle call hanging on a prompt nobody will answer. */
32
+ const SSH_OPTS = ["-o", "BatchMode=yes", "-o", "ConnectTimeout=15"];
33
+
34
+ const ID_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
35
+ const SSH_HOST_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; // an OpenSSH alias or host name, never a user@ or option
36
+
37
+ export function serverError(code, message) {
38
+ return Object.assign(new Error(message), { code });
39
+ }
40
+
41
+ // ---------------------------------------------------------------- registry
42
+
43
+ export function readServers() {
44
+ const file = SERVERS_FILE();
45
+ if (!existsSync(file)) return {};
46
+ let doc;
47
+ try {
48
+ doc = JSON.parse(readFileSync(file, "utf8"));
49
+ } catch (e) {
50
+ throw serverError("E_SERVERS_UNREADABLE", `${file} is not valid JSON: ${e.message}`);
51
+ }
52
+ const servers = doc && typeof doc === "object" && !Array.isArray(doc) ? doc.servers : undefined;
53
+ if (!servers || typeof servers !== "object" || Array.isArray(servers)) {
54
+ throw serverError("E_SERVERS_UNREADABLE", `${file} must be { "servers": { <id>: {...} } }`);
55
+ }
56
+ for (const [id, s] of Object.entries(servers)) validateServer(id, s);
57
+ return servers;
58
+ }
59
+
60
+ export function writeServers(servers) {
61
+ const file = SERVERS_FILE();
62
+ mkdirSync(OATS_HOME_DIR(), { recursive: true });
63
+ writeFileSync(file, JSON.stringify({ servers }, null, 2) + "\n", { mode: 0o600 });
64
+ }
65
+
66
+ /** A registration names WHERE and HOW, never WITH WHAT credentials. */
67
+ export function validateServer(id, s) {
68
+ if (!ID_RE.test(String(id))) throw serverError("E_SERVER_INVALID", `server id ${JSON.stringify(id)} must be lowercase letters, digits and dashes`);
69
+ if (!s || typeof s !== "object" || Array.isArray(s)) throw serverError("E_SERVER_INVALID", `server ${id}: entry must be an object`);
70
+ if (typeof s.sshHost !== "string" || !SSH_HOST_RE.test(s.sshHost)) throw serverError("E_SERVER_INVALID", `server ${id}: sshHost must be an OpenSSH host alias or host name (no user@, no options)`);
71
+ if (typeof s.workspace !== "string" || !s.workspace.startsWith("/")) throw serverError("E_SERVER_INVALID", `server ${id}: workspace must be an absolute path on the server`);
72
+ for (const k of ["oatsPath", "herdrPath", "label", "path"]) {
73
+ if (s[k] !== undefined && s[k] !== null && typeof s[k] !== "string") throw serverError("E_SERVER_INVALID", `server ${id}: ${k} must be a string`);
74
+ }
75
+ if (s.path !== undefined && s.path !== null && !s.path.split(":").every((d) => d.startsWith("/") || d.startsWith("~/"))) throw serverError("E_SERVER_INVALID", `server ${id}: path must be absolute directories on the server, colon-separated`);
76
+ for (const k of Object.keys(s)) {
77
+ if (!["label", "sshHost", "workspace", "oatsPath", "herdrPath", "path"].includes(k)) throw serverError("E_SERVER_INVALID", `server ${id}: unknown field ${JSON.stringify(k)} (a registration holds label, sshHost, workspace, oatsPath, herdrPath, path and nothing else — never keys or passwords)`);
78
+ }
79
+ return true;
80
+ }
81
+
82
+ export function getServer(id) {
83
+ const servers = readServers();
84
+ const s = servers[id];
85
+ if (!s) throw serverError("E_SERVER_UNKNOWN", `no server registered as ${JSON.stringify(id)} (oats server list)`);
86
+ return { id, ...s };
87
+ }
88
+
89
+ // ------------------------------------------------------------------ quoting
90
+
91
+ /** POSIX single-quoting for the REMOTE shell: ssh joins its command words
92
+ * with spaces and hands the string to the login shell, so every argument
93
+ * must survive that shell untouched. Single quotes are the only construct
94
+ * in which nothing is special; an embedded quote closes, escapes, reopens. */
95
+ export function remoteQuote(arg) {
96
+ const s = String(arg);
97
+ if (s === "") return "''";
98
+ if (/^[A-Za-z0-9._\/=:@%+,-]+$/.test(s)) return s;
99
+ return `'${s.replace(/'/g, `'\\''`)}'`;
100
+ }
101
+
102
+ /** The exact snapshot of a registration that a route runs against. */
103
+ export function targetOf(server) {
104
+ return {
105
+ sshHost: server.sshHost,
106
+ workspace: server.workspace,
107
+ oatsPath: server.oatsPath || "oats",
108
+ ...(server.herdrPath ? { herdrPath: server.herdrPath } : {}),
109
+ ...(server.path ? { path: server.path } : {}),
110
+ };
111
+ }
112
+
113
+ /** argv for the local ssh process that runs one remote oats command. `--`
114
+ * ends ssh's own options so a host alias can never be read as one; the
115
+ * remote command is one quoted string, as ssh requires. */
116
+ /** A stable key for a route target: two registrations may name the same
117
+ * host and workspace, and a registration may change; groups and guards key
118
+ * on the target, never on the registry id alone. */
119
+ export function targetKey(target) {
120
+ // Host and workspace only: the binary path (like --path and --label) is how
121
+ // to reach the same homes, not which homes; each snapshot keeps its own.
122
+ return createHash("sha256").update(`${target.sshHost}\0${target.workspace}`).digest("hex").slice(0, 12);
123
+ }
124
+
125
+ export function sshArgv(target, oatsArgs, { cwd } = {}) {
126
+ // A non-interactive ssh command runs in the login shell's minimal PATH,
127
+ // which rarely includes user-local tool directories (~/.local/bin, where
128
+ // claude and pi commonly live). A registration may name directories to
129
+ // prepend; `$PATH` stays unquoted so the remote shell expands its own.
130
+ // A leading ~/ means the remote user's home: it becomes an unquoted "$HOME"
131
+ // concatenated with the quoted remainder, so the remote shell expands the
132
+ // one and never touches the other.
133
+ const dirs = target.path ? target.path.split(":").filter(Boolean).map((d) => (d.startsWith("~/") ? `"$HOME"${remoteQuote(d.slice(1))}` : remoteQuote(d))) : [];
134
+ const prefix = dirs.length ? `PATH=${dirs.join(":")}:"$PATH" ` : "";
135
+ // An optional working directory for commands that read their instance
136
+ // from cwd (oats okf harvest): a quoted cd, never a path from the caller.
137
+ const cmd = (cwd ? `cd ${remoteQuote(cwd)} && ` : "") + prefix + [target.oatsPath, ...oatsArgs].map(remoteQuote).join(" ");
138
+ return ["ssh", ...SSH_OPTS, "--", target.sshHost, cmd];
139
+ }
140
+
141
+ // ------------------------------------------------------------------ running
142
+
143
+ /** Parse the remote kernel's JSON envelope; anything else on stdout is a
144
+ * routing failure with the raw text attached, never a guess. */
145
+ export function parseRemoteEnvelope(stdout) {
146
+ const text = String(stdout || "").trim();
147
+ let doc;
148
+ try {
149
+ doc = JSON.parse(text);
150
+ } catch {
151
+ throw serverError("E_REMOTE_ENVELOPE", `the remote oats did not answer with a JSON envelope: ${text.slice(0, 300) || "(empty stdout)"}`);
152
+ }
153
+ if (!doc || typeof doc !== "object") {
154
+ throw serverError("E_REMOTE_ENVELOPE", `the remote oats answered with an unsupported envelope: ${text.slice(0, 300)}`);
155
+ }
156
+ // `status --json` predates the envelope and answers the bare roster
157
+ // { root, agents } (team form: { team, roots }); normalize it too.
158
+ if (doc.schemaVersion === undefined && (Array.isArray(doc.agents) || Array.isArray(doc.roots))) {
159
+ return { schemaVersion: 1, ok: true, result: doc, bare: true };
160
+ }
161
+ // `retire --json` likewise answers its bare result object (oats-1lb).
162
+ if (doc.schemaVersion === undefined && typeof doc.retired === "string") {
163
+ return { schemaVersion: 1, ok: true, result: doc, bare: true };
164
+ }
165
+ if (doc.schemaVersion !== 1) {
166
+ throw serverError("E_REMOTE_ENVELOPE", `the remote oats answered with an unsupported envelope: ${text.slice(0, 300)}`);
167
+ }
168
+ // `version --json` answers the Desktop API v1 probe payload rather than
169
+ // an ok/result envelope; normalize it so every caller sees one shape.
170
+ if (typeof doc.ok !== "boolean") {
171
+ if (doc.desktopApi === 1 && typeof doc.version === "string") return { schemaVersion: 1, ok: true, result: doc, probe: true };
172
+ throw serverError("E_REMOTE_ENVELOPE", `the remote oats answered with an unsupported envelope: ${text.slice(0, 300)}`);
173
+ }
174
+ return doc;
175
+ }
176
+
177
+ /** Run one remote oats command and return its envelope. A non-zero exit with
178
+ * a well-formed failure envelope is returned as that envelope (the remote
179
+ * kernel's own error code and message); ssh's own failures (unreachable,
180
+ * refused key, unknown host) surface as E_SSH with ssh's stderr. */
181
+ export function runRemote(target, oatsArgs, io = {}) {
182
+ const exec = io.execFileSync || execFileSync;
183
+ const [bin, ...argv] = sshArgv(target, oatsArgs, { cwd: io.cwd });
184
+ let stdout = "";
185
+ let stderr = "";
186
+ let status = 0;
187
+ try {
188
+ stdout = exec(bin, argv, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 16 * 1024 * 1024, timeout: io.timeoutMs || 300000 });
189
+ } catch (e) {
190
+ stdout = String(e.stdout || "");
191
+ stderr = String(e.stderr || e.message || "");
192
+ status = typeof e.status === "number" ? e.status : 255;
193
+ }
194
+ if (String(stdout).trim()) {
195
+ const envelope = parseRemoteEnvelope(stdout);
196
+ // A bare `retire --json` answer with cleanup still owed exits 1 on the
197
+ // remote; the normalized envelope must say so too, or a routed retire
198
+ // reports success while the remote home and its external state remain.
199
+ if (envelope.bare && envelope.result?.rollbackIncomplete) {
200
+ return { envelope: { ...envelope, ok: false, error: { code: "E_RETIRE_INCOMPLETE", message: `cleanup on the server is INCOMPLETE; the home ${envelope.result.retainedHome || ""} is retained there: ${envelope.result.rollbackIncomplete.join("; ")}` } }, stderr, status };
201
+ }
202
+ return { envelope, stderr, status };
203
+ }
204
+ // ssh exits 255 for its own failures; the remote command's exit code is
205
+ // relayed otherwise. Either way with no envelope there is nothing to trust.
206
+ throw serverError(status === 255 ? "E_SSH" : "E_REMOTE_ENVELOPE", `${status === 255 ? "ssh failed" : `remote oats exited ${status} with no envelope`}: ${stderr.trim().slice(0, 400) || "(no output)"}`);
207
+ }
208
+
209
+ /** Version and envelope compatibility, checked BEFORE any mutation. The
210
+ * remote must answer `version --json` with the Desktop API v1 probe payload
211
+ * and a kernel the local side knows how to talk to. */
212
+ export function checkRemote(target, io = {}) {
213
+ const { envelope } = runRemote(target, ["version", "--json"], { ...io, timeoutMs: 60000 });
214
+ const probe = envelope.result || {};
215
+ if (probe.desktopApi !== 1 || typeof probe.version !== "string") {
216
+ throw serverError("E_REMOTE_INCOMPATIBLE", `remote oats at ${target.sshHost} answered an unknown version payload: ${JSON.stringify(probe).slice(0, 200)}`);
217
+ }
218
+ const min = io.minVersion || MIN_REMOTE_VERSION;
219
+ if (compareSemver(probe.version, min) < 0) {
220
+ throw serverError("E_REMOTE_INCOMPATIBLE", `remote oats ${probe.version} at ${target.sshHost} is older than the minimum ${min} this kernel routes to; upgrade it there`);
221
+ }
222
+ // What the remote kernel advertises it can launch. A probe without these
223
+ // fields is a 0.22.1-class kernel: pi and claude only, no session backend
224
+ // choice, no launch options; nothing newer may be requested of it.
225
+ const list = (k, fallback) => (Array.isArray(probe[k]) ? probe[k].map(String) : fallback);
226
+ return {
227
+ version: probe.version, schemaVersion: 1, desktopApi: 1,
228
+ runtimes: list("runtimes", ["pi", "claude"]),
229
+ sessionBackends: list("sessionBackends", []),
230
+ launchOptions: list("launchOptions", []),
231
+ remote: list("remote", []),
232
+ features: list("features", []),
233
+ advertised: Array.isArray(probe.runtimes),
234
+ };
235
+ }
236
+
237
+ /** Refuse, before any mutation, a spawn that asks the remote kernel for a
238
+ * runtime, session backend or launch option it does not advertise. The
239
+ * effective runtime is the --runtime flag or the soul's default from the
240
+ * remote roster, resolved THERE, since the local roster says nothing about
241
+ * that host's souls. Desktop's local support check cannot prove remote
242
+ * support; this is the proof. */
243
+ export function checkRemoteSupport(remote, target, oatsArgs, roster) {
244
+ const flagOf = (name) => { const i = oatsArgs.indexOf(name); return i >= 0 && oatsArgs[i + 1] && !oatsArgs[i + 1].startsWith("--") ? oatsArgs[i + 1] : undefined; };
245
+ const agent = oatsArgs.find((a) => !a.startsWith("--"));
246
+ const soul = (roster?.agents || []).find((a) => a.name === agent);
247
+ // The effective runtime is the flag, else the soul's default as the remote
248
+ // roster reports it. A soul the roster does not list with a runtime (a
249
+ // capability-defined agent, or one with no live instance) has no default
250
+ // this side can establish: the remote kernel validates its own default at
251
+ // spawn, and nothing is asserted here about it.
252
+ const runtime = flagOf("--runtime") || soul?.runtime;
253
+ // What the message may claim depends on what was established: an
254
+ // advertising remote said what it supports; a silent one (before 0.22.2)
255
+ // said nothing, and only pi and claude are assumed of it.
256
+ const supports = remote.advertised
257
+ ? `it advertises runtimes ${remote.runtimes.join(", ")}${remote.sessionBackends.length ? `, session backends ${remote.sessionBackends.join(", ")}` : ", no session backend choice"}${remote.launchOptions.length ? `, launch options ${remote.launchOptions.join(", ")}` : ", no launch options"}`
258
+ : `it does not advertise what it supports (kernels before 0.22.2 do not), so only pi and claude on tmux with no launch options are assumed of it; upgrade it there to use more`;
259
+ const refuse = (what) => { throw serverError("E_REMOTE_INCOMPATIBLE", `remote oats ${remote.version} at ${target.sshHost}: ${what} was not established as supported there (${supports})`); };
260
+ if (runtime && !remote.runtimes.includes(runtime)) refuse(`runtime ${runtime}${flagOf("--runtime") ? "" : ` (the default of soul ${agent} there)`}`);
261
+ const backend = flagOf("--backend");
262
+ if (backend && !remote.sessionBackends.includes(backend)) refuse(`session backend ${backend}`);
263
+ if (oatsArgs.includes("--yolo") && !remote.launchOptions.includes("yolo")) refuse("the yolo launch option");
264
+ return { runtime, backend, yolo: oatsArgs.includes("--yolo") };
265
+ }
266
+
267
+ /** The remote kernel version that carries `oats session` (inspect, input,
268
+ * attach): routed session commands need at least this. */
269
+ export const SESSION_REMOTE_VERSION = "0.22.2";
270
+
271
+ /** The oldest remote kernel this router talks to: 0.22.1 answers the v1
272
+ * probe and roster and runs the lifecycle over ssh (qualified live on
273
+ * aweb-agents), but knows nothing of session backends, launch options,
274
+ * deferred self-retirement or the `oats session` commands (all 0.22.2);
275
+ * checkRemoteSupport keeps spawn requests within what a given remote
276
+ * advertises and session routes require SESSION_REMOTE_VERSION. The floor
277
+ * moves to 0.22.2 with that release. */
278
+ export const MIN_REMOTE_VERSION = "0.22.1";
279
+
280
+ export function compareSemver(a, b) {
281
+ const pa = String(a).split(/[.-]/).map((x) => Number.parseInt(x, 10));
282
+ const pb = String(b).split(/[.-]/).map((x) => Number.parseInt(x, 10));
283
+ for (let i = 0; i < 3; i++) {
284
+ const d = (pa[i] || 0) - (pb[i] || 0);
285
+ if (d) return d;
286
+ }
287
+ return 0;
288
+ }
289
+
290
+ // ---------------------------------------------------------------- snapshots
291
+
292
+ export function snapshotPath(serverId, instance) {
293
+ if (!ID_RE.test(String(serverId))) throw serverError("E_SERVER_INVALID", `bad server id ${JSON.stringify(serverId)}`);
294
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(String(instance))) throw serverError("E_BAD_ARGS", `bad instance name ${JSON.stringify(instance)}`);
295
+ return join(REMOTE_SNAPSHOT_DIR(), serverId, `${instance}.json`);
296
+ }
297
+
298
+ /** The local representation of a remote instance: the route it was spawned
299
+ * through, frozen. `serverId` is for display; every later operation uses
300
+ * `target`. Remote state is never copied here — status is pulled. */
301
+ export function writeSnapshot(serverId, target, remote, spawnResult) {
302
+ const snap = {
303
+ serverId,
304
+ target,
305
+ remote: { version: remote.version, schemaVersion: remote.schemaVersion },
306
+ instance: spawnResult.instance,
307
+ agent: spawnResult.agent,
308
+ home: spawnResult.home,
309
+ agentsRoot: spawnResult.agentsRoot,
310
+ spawnedAt: new Date().toISOString(),
311
+ };
312
+ const p = snapshotPath(serverId, spawnResult.instance);
313
+ mkdirSync(join(REMOTE_SNAPSHOT_DIR(), serverId), { recursive: true });
314
+ writeFileSync(p, JSON.stringify(snap, null, 2) + "\n");
315
+ return snap;
316
+ }
317
+
318
+ export function readSnapshot(serverId, instance) {
319
+ const p = snapshotPath(serverId, instance);
320
+ if (!existsSync(p)) return undefined;
321
+ try {
322
+ return JSON.parse(readFileSync(p, "utf8"));
323
+ } catch (e) {
324
+ throw serverError("E_SNAPSHOT_UNREADABLE", `${p}: ${e.message}`);
325
+ }
326
+ }
327
+
328
+ export function removeSnapshot(serverId, instance) {
329
+ rmSync(snapshotPath(serverId, instance), { force: true });
330
+ }
331
+
332
+ /** Drop a saved route on purpose: the remote instance is gone (retired on the
333
+ * host, home removed, host rebuilt) and nothing routed can remove the
334
+ * snapshot any more. The operator's decision, never automatic. */
335
+ export function forgetSnapshot(serverId, instance) {
336
+ const p = snapshotPath(serverId, instance);
337
+ if (!existsSync(p)) throw serverError("E_SNAPSHOT_UNKNOWN", `no saved route for ${instance} through server ${serverId} (oats server roster --json)`);
338
+ const snap = readSnapshot(serverId, instance);
339
+ rmSync(p, { force: true });
340
+ return snap;
341
+ }
342
+
343
+ export function listSnapshots(serverId) {
344
+ const dir = join(REMOTE_SNAPSHOT_DIR(), serverId);
345
+ if (!existsSync(dir)) return [];
346
+ const out = [];
347
+ for (const f of readdirSync(dir)) {
348
+ if (!f.endsWith(".json")) continue;
349
+ try {
350
+ out.push(JSON.parse(readFileSync(join(dir, f), "utf8")));
351
+ } catch {
352
+ out.push({ serverId, instance: f.replace(/\.json$/, ""), unreadable: true });
353
+ }
354
+ }
355
+ return out;
356
+ }
357
+
358
+ // ------------------------------------------------------------------- routes
359
+
360
+ /** Route one lifecycle command to a server. Returns the remote envelope,
361
+ * having kept the local snapshot store in step with it:
362
+ * - spawn: compatibility checked first, a snapshot written on success;
363
+ * - retire: the target comes from the instance's snapshot when one exists
364
+ * (the registry may have changed since), the snapshot is removed only
365
+ * when the remote kernel reports the home gone;
366
+ * - status: pulled from the remote kernel, never cached. */
367
+ export function routeCommand(serverId, cmd, oatsArgs, io = {}) {
368
+ // A retirement may outlive its registration: the snapshot taken at spawn
369
+ // is the route, and it is consulted before the registry is required.
370
+ const instanceArg = oatsArgs.find((a) => !a.startsWith("--"));
371
+ const snap = ["retire", "harvest"].includes(cmd) && instanceArg ? readSnapshot(serverId, instanceArg) : undefined;
372
+ let server = io.server;
373
+ if (!server) {
374
+ try { server = getServer(serverId); }
375
+ catch (e) { if (!snap?.target) throw e; }
376
+ }
377
+ let target = snap?.target || targetOf(server);
378
+ const withScope = (args) => (args.includes("--dir") ? args : [...args, "--dir", target.workspace]);
379
+ const json = (args) => (args.includes("--json") ? args : [...args, "--json"]);
380
+
381
+ if (cmd === "spawn") {
382
+ // A registration edited to another target must not overwrite the saved
383
+ // routes of instances spawned through the old one: the next snapshot for
384
+ // the same name would silently retarget them. Refuse, naming the remedy.
385
+ const priorTargets = listSnapshots(serverId).filter((s) => s.target && targetKey(s.target) !== targetKey(target));
386
+ if (priorTargets.length) {
387
+ const old = priorTargets[0].target;
388
+ throw serverError("E_ROUTE_CHANGED", `server ${serverId} now points at ${target.sshHost}:${target.workspace}, but ${priorTargets.length} saved route${priorTargets.length === 1 ? "" : "s"} for it (${priorTargets.map((s) => s.instance).join(", ")}) target ${old.sshHost}:${old.workspace}; spawning would overwrite them. Keep the old registration and add a new server id for the new target, or retire those instances first`);
389
+ }
390
+ const remote = checkRemote(target, io);
391
+ // A saved route is keyed by name under its server id. An explicit name
392
+ // that already has one here would overwrite that route on success: refuse
393
+ // before the mutation (the roster shows the existing route; forget or
394
+ // retire it first).
395
+ const ii = oatsArgs.indexOf("--instance");
396
+ const explicitName = ii >= 0 && oatsArgs[ii + 1] && !oatsArgs[ii + 1].startsWith("--") ? oatsArgs[ii + 1] : undefined;
397
+ if (explicitName && readSnapshot(serverId, explicitName)) throw serverError("E_ROUTE_EXISTS", `a saved route for ${explicitName} through server ${serverId} already exists (${readSnapshot(serverId, explicitName).home}); retire it (oats retire ${explicitName} --server ${serverId}) or drop it (oats server forget ${serverId} --instance ${explicitName}) before spawning that name again`);
398
+ // The remote roster: the soul's runtime default for the support check,
399
+ // and the remote agents root for the snapshot (the kernel's spawn result
400
+ // does not carry it, and guessing it from the workspace would be wrong
401
+ // for local souls).
402
+ const status = runRemote(target, json(withScope(["status"])), io).envelope;
403
+ if (!status.ok) return { envelope: status, stderr: "" };
404
+ checkRemoteSupport(remote, target, oatsArgs, status.result);
405
+ const { envelope, stderr } = runRemote(target, json(withScope(["spawn", ...oatsArgs])), io);
406
+ if (envelope.ok && envelope.result?.instance) {
407
+ // A generated name can still collide with a saved route of another
408
+ // soul on the same host (dev --purpose foo-1 vs dev-foo --purpose 1).
409
+ // The existing route is never overwritten: the new instance is
410
+ // reported without a saved route, to be retired on the host by --home.
411
+ // An existing route is replaced only by a result that provably names
412
+ // the same home; absent data is never permission to overwrite it.
413
+ const prior = readSnapshot(serverId, envelope.result.instance);
414
+ const sameAsPrior = prior && prior.home && envelope.result.home && resolve(prior.home) === resolve(envelope.result.home);
415
+ if (prior && !sameAsPrior) {
416
+ const warnings = [...(envelope.result.warnings || []), `no saved route: ${envelope.result.instance} already names ${prior.home} through ${serverId}; this instance (${envelope.result.home}) is not managed from here, retire it on the host with oats retire ${envelope.result.instance} --home ${envelope.result.home}`];
417
+ return { envelope: { ...envelope, result: { ...envelope.result, server: serverId, target, snapshot: null, routeConflict: { instance: envelope.result.instance, existingHome: prior.home }, warnings } }, stderr };
418
+ }
419
+ const snapshot = writeSnapshot(serverId, target, remote, { ...envelope.result, agentsRoot: envelope.result.agentsRoot || status.result.root });
420
+ return { envelope: { ...envelope, result: { ...envelope.result, server: serverId, target, snapshot: snapshotPath(serverId, envelope.result.instance) } }, stderr, snapshot };
421
+ }
422
+ return { envelope, stderr };
423
+ }
424
+ if (cmd === "retire") {
425
+ const name = instanceArg;
426
+ const remote = checkRemote(target, io); // a mutation: version and envelope first, like spawn
427
+ // The saved route knows WHICH home was spawned through it; a remote kernel
428
+ // that resolves --home retires that one, never a same-named twin. A
429
+ // caller's explicit --home must be that same home: anything else would
430
+ // retire a sibling and then drop this instance's saved route.
431
+ const hi = oatsArgs.indexOf("--home");
432
+ const explicitHome = hi >= 0 && oatsArgs[hi + 1] && !oatsArgs[hi + 1].startsWith("--") ? oatsArgs[hi + 1] : undefined;
433
+ if (explicitHome && snap?.home && resolve(explicitHome) !== resolve(snap.home)) throw serverError("E_HOME_MISMATCH", `--home ${explicitHome} is not the saved route of ${name} on ${serverId} (${snap.home}); retire the saved one, or retire the other instance on the host by its own name`);
434
+ const remoteRetiresByHome = Array.isArray(remote?.features) && remote.features.includes("retire-home");
435
+ const wantHome = explicitHome || snap?.home;
436
+ if (wantHome && !remoteRetiresByHome) {
437
+ // An older kernel ignores --home and retires by name, first match. It
438
+ // is safe only when the name is unique there and is the saved home;
439
+ // an explicit --home is never sent where it cannot be honoured.
440
+ if (explicitHome) throw serverError("E_REMOTE_INCOMPATIBLE", `remote oats ${remote.version} at ${target.sshHost} cannot retire by home (0.22.3 or later does); upgrade it there, or retire ${name} without --home once it is the only instance of that name`);
441
+ const st = runRemote(target, ["status", "--json", "--dir", target.workspace], io).envelope;
442
+ if (!st.ok) throw serverError(st.error?.code || "E_REMOTE", `cannot check ${name} on ${serverId} before retiring by name: ${st.error?.message || "status failed"}`);
443
+ const twins = (st.result?.agents || []).flatMap((a) => (a.instances || []).filter((i) => i.instance === name).map((i) => ({ agent: a.name, home: i.home })));
444
+ if (twins.length > 1) throw serverError("E_REMOTE_INCOMPATIBLE", `${name} names ${twins.length} instances on ${serverId} (${twins.map((t) => t.agent).join(", ")}) and remote oats ${remote.version} retires by name only; upgrade it to 0.22.3 or later so the saved home is retired and not a twin`);
445
+ if (twins.length === 1 && twins[0].home && resolve(twins[0].home) !== resolve(wantHome)) throw serverError("E_HOME_MISMATCH", `the only ${name} on ${serverId} lives at ${twins[0].home}, not at the saved route ${wantHome}; the saved route is stale (oats server roster --json)`);
446
+ }
447
+ const homeArgs = remoteRetiresByHome && wantHome && !explicitHome ? ["--home", wantHome] : [];
448
+ const { envelope, stderr } = runRemote(target, json(withScope(["retire", ...oatsArgs, ...homeArgs])), io);
449
+ // The snapshot goes only when the remote home is gone: not on incomplete
450
+ // cleanup, and not on a deferred completion still on its way there.
451
+ if (envelope.ok && name && envelope.result?.removedDir !== false && !envelope.result?.rollbackIncomplete && !envelope.result?.deferred) removeSnapshot(serverId, name);
452
+ // The routing context rides on the result whenever there is one, ok or
453
+ // not: an incomplete cleanup is exactly when the operator needs the host.
454
+ return { envelope: envelope.result ? { ...envelope, result: { ...envelope.result, server: serverId, target } } : envelope, stderr };
455
+ }
456
+ if (cmd === "harvest") {
457
+ // `oats okf harvest --json` reads its instance from cwd: run it in the
458
+ // SAVED home of an instance spawned through this route (no path from
459
+ // the caller), relaying the package's own envelope.
460
+ const name = instanceArg;
461
+ if (!snap?.home) throw serverError("E_SNAPSHOT_UNKNOWN", `no remote instance ${JSON.stringify(name || "")} spawned through server ${serverId} from this machine (oats server roster --json)`);
462
+ // A mutation on the host (it spawns a harvester there): version and
463
+ // envelope first. The remote list is a kernel-version proxy (0.22.3
464
+ // introduced this route and the envelope boundary it relies on); an
465
+ // older okf package there would still answer outside the envelope, which
466
+ // then fails as E_REMOTE_ENVELOPE rather than as a bad harvest.
467
+ const remote = checkRemote(target, io);
468
+ if (!Array.isArray(remote.remote) || !remote.remote.includes("harvest")) throw serverError("E_REMOTE_INCOMPATIBLE", `remote oats ${remote.version} at ${target.sshHost} does not route okf harvest (0.22.3 or later does)`);
469
+ const { envelope, stderr } = runRemote(target, ["okf", "harvest", "--json"], { ...io, cwd: snap.home });
470
+ // The package's result names the HARVESTER it spawned (instance, window);
471
+ // the routing context goes under its own keys.
472
+ return { envelope: envelope.result ? { ...envelope, result: { ...envelope.result, server: serverId, sourceInstance: name, sourceHome: snap.home } } : envelope, stderr };
473
+ }
474
+ if (cmd === "status") {
475
+ const { envelope, stderr } = runRemote(target, json(withScope(["status", ...oatsArgs])), io);
476
+ return { envelope: envelope.ok ? { ...envelope, result: { ...envelope.result, server: serverId, target, snapshots: listSnapshots(serverId) } } : envelope, stderr };
477
+ }
478
+ throw serverError("E_USAGE", `--server routes spawn, retire, status, session and okf harvest only (not ${cmd})`);
479
+ }
480
+
481
+ // ------------------------------------------------------------------- roster
482
+
483
+ // The roster answers within a budget the Desktop can wait for (its adapter
484
+ // allows 60 s): each target gets the smaller of its own allowance and what is
485
+ // left, and targets the budget cannot reach are reported as such, never
486
+ // dropped and never allowed to sink the healthy groups.
487
+ export const ROSTER_BUDGET_MS = 45000;
488
+ export const ROSTER_PER_TARGET_TIMEOUT_MS = 20000;
489
+
490
+ function listSnapshotServers() {
491
+ const dir = REMOTE_SNAPSHOT_DIR();
492
+ if (!existsSync(dir)) return [];
493
+ return readdirSync(dir, { withFileTypes: true }).filter((e) => e.isDirectory() && ID_RE.test(e.name)).map((e) => e.name);
494
+ }
495
+
496
+ /** The remote roster, grouped by server id and saved route target: one
497
+ * bounded status pull per group, registrations unioned with saved routes so
498
+ * a removed or changed registration keeps its group (registrationPresent
499
+ * false) and its instances from the snapshots (running null when the probe
500
+ * fails). Remote state is pulled, never cached; the saved route stays the
501
+ * action authority. */
502
+ export function rosterGroups({ server, io = {} } = {}) {
503
+ const started = Date.now();
504
+ if (server && !ID_RE.test(server)) throw serverError("E_BAD_ARGS", `server id ${JSON.stringify(server)} is not a valid id`);
505
+ if (server && !readServers()[server] && !existsSync(join(REMOTE_SNAPSHOT_DIR(), server))) throw serverError("E_SERVER_UNKNOWN", `no server registered as ${JSON.stringify(server)} and no saved routes for it (oats server list)`);
506
+ const perTargetTimeoutMs = io.perTargetTimeoutMs || ROSTER_PER_TARGET_TIMEOUT_MS;
507
+ const budgetMs = io.budgetMs || ROSTER_BUDGET_MS;
508
+ let skipped = 0;
509
+ const servers = readServers();
510
+ const groups = new Map();
511
+ const add = (serverId, target, registrationPresent, label) => {
512
+ const key = `${serverId}:${targetKey(target)}`;
513
+ if (!groups.has(key)) groups.set(key, { id: key, server: serverId, label: label || serverId, registrationPresent, target, probe: null, agentsRoot: undefined, souls: [], instances: [], retireFailures: [], _snapshots: [] });
514
+ const g = groups.get(key);
515
+ if (registrationPresent) g.registrationPresent = true;
516
+ return g;
517
+ };
518
+ for (const [id, s] of Object.entries(servers)) { if (server && id !== server) continue; add(id, targetOf({ id, ...s }), true, s.label); }
519
+ for (const serverId of listSnapshotServers()) {
520
+ if (server && serverId !== server) continue;
521
+ for (const snap of listSnapshots(serverId)) { if (!snap.target) continue; add(serverId, snap.target, false, servers[serverId]?.label)._snapshots.push(snap); }
522
+ }
523
+ for (const g of groups.values()) {
524
+ let status;
525
+ const remaining = budgetMs - (Date.now() - started);
526
+ if (remaining < 1000) {
527
+ skipped++;
528
+ g.probe = { ok: false, error: { code: "E_ROSTER_BUDGET", message: `not probed: the ${budgetMs} ms roster budget was used up by earlier targets` } };
529
+ } else {
530
+ try { status = runRemote(g.target, ["status", "--json", "--dir", g.target.workspace], { ...io, timeoutMs: Math.min(perTargetTimeoutMs, remaining) }).envelope; }
531
+ catch (e) { g.probe = { ok: false, error: { code: e.code || "E_SSH", message: e.message } }; }
532
+ }
533
+ if (status && !status.ok) g.probe = { ok: false, error: status.error || { code: "E_REMOTE", message: "status failed" } };
534
+ // A saved route matches a remote row by name AND home: a same-named
535
+ // twin under another soul on the host is observed only, never given the
536
+ // route (the route's own row is appended as stale if the host no longer
537
+ // lists that home).
538
+ const bySnapshot = new Map(g._snapshots.map((s) => [s.instance, s]));
539
+ const routeOf = (i) => { const s = bySnapshot.get(i.instance); return s && s.home && i.home && resolve(s.home) === resolve(i.home) ? s : undefined; };
540
+ if (status?.ok) {
541
+ g.probe = { ok: true };
542
+ g.agentsRoot = status.result.root;
543
+ for (const a of status.result.agents || []) {
544
+ g.souls.push({ name: a.name, runtime: a.runtime, work: a.work, backend: a.backend, description: a.description, agentsRoot: status.result.root });
545
+ // A failed deferred self-retirement needs an operator: it rides with
546
+ // the group, named by agent and instance, as `oats status` prints it.
547
+ for (const f of a.retireFailures || []) g.retireFailures.push({ agent: a.name, ...f });
548
+ for (const i of a.instances || []) {
549
+ const snap = routeOf(i);
550
+ g.instances.push({
551
+ server: g.server, instance: i.instance, agent: a.name, home: i.home || snap?.home, agentsRoot: status.result.root,
552
+ runtime: i.runtime || null, backend: i.sessionTarget?.backend || (i.tmux ? "tmux" : null),
553
+ ...(i.sessionTarget ? { sessionTarget: i.sessionTarget } : {}), ...(i.tmux ? { tmux: i.tmux } : {}),
554
+ running: typeof i.running === "boolean" ? i.running : null, ...(i.runtimeError ? { runtimeError: i.runtimeError } : {}),
555
+ retirePending: !!i.retirePending, rollbackIncomplete: !!i.rollbackIncomplete, savedRoute: !!snap, missingRemotely: false,
556
+ });
557
+ if (snap) bySnapshot.delete(i.instance);
558
+ }
559
+ }
560
+ }
561
+ // Saved routes the remote did not list: with a good probe that means the
562
+ // instance is gone there (retired, or its home removed) and the route is
563
+ // stale; with a failed probe nothing is known. Same keys as remote rows.
564
+ for (const snap of bySnapshot.values()) {
565
+ g.instances.push({ server: g.server, instance: snap.instance, agent: snap.agent, home: snap.home, agentsRoot: snap.agentsRoot, runtime: snap.runtime || null, backend: null, running: null, retirePending: false, rollbackIncomplete: false, savedRoute: true, missingRemotely: g.probe?.ok === true });
566
+ }
567
+ delete g._snapshots;
568
+ }
569
+ return { groups: [...groups.values()], bounds: { budgetMs, perTargetTimeoutMs, elapsedMs: Date.now() - started, skipped } };
570
+ }
571
+
572
+ /** argv for an INTERACTIVE remote command (a viewer attach): ssh with a PTY,
573
+ * stdio inherited by the caller. The home is resolved from the instance's
574
+ * snapshot when only an instance name is given, so the route is the saved
575
+ * one and nothing about the remote binary or path comes from the caller. */
576
+ /** The saved route for a remote instance: the snapshot's target and home
577
+ * when an instance name is given (spawned from here), else the registry's
578
+ * target with a caller-supplied absolute remote home. Nothing about the
579
+ * remote binary or path ever comes from the caller. */
580
+ export function resolveRoute(serverId, { instance, home } = {}, what = "session") {
581
+ // The home is the identity (same-named twins on one host are different
582
+ // instances); a name alone resolves through its saved route. With a home,
583
+ // the saved route that owns it supplies the target (a registration edited
584
+ // later never re-routes a viewer); a name AND a home must agree.
585
+ let snap = instance ? readSnapshot(serverId, instance) : undefined;
586
+ if (instance && !snap) throw serverError("E_SNAPSHOT_UNKNOWN", `no remote instance ${JSON.stringify(instance)} spawned through server ${serverId} from this machine (oats status --server ${serverId})`);
587
+ if (home && snap?.home && resolve(snap.home) !== resolve(home)) throw serverError("E_HOME_MISMATCH", `--home ${home} is not the saved route of ${instance} on ${serverId} (${snap.home})`);
588
+ if (home && !snap) snap = listSnapshots(serverId).find((s) => s.home && resolve(s.home) === resolve(home));
589
+ const target = snap?.target || targetOf(getServer(serverId));
590
+ const remoteHome = home || snap?.home;
591
+ if (!remoteHome || !String(remoteHome).startsWith("/")) throw serverError("E_BAD_ARGS", `${what} --server needs --instance <name> (spawned from here) or --home </absolute/remote/home>`);
592
+ return { target, home: remoteHome, snapshot: snap };
593
+ }
594
+
595
+ /** `session inspect` on the execution host for a remote instance: the same
596
+ * route resolution as attach, the standard envelope relayed as is (ok or
597
+ * not), never an ACK of anything. */
598
+ /** A version is a proxy; the probe's `remote` list states the capability
599
+ * directly: a kernel that routes session itself also serves it. A silent
600
+ * probe (before 0.22.2) or one without session in the list is refused
601
+ * before any session command is sent. */
602
+ function requireSessionRemote(target, io) {
603
+ const remote = checkRemote(target, io);
604
+ if (!remote.remote.includes("session")) {
605
+ throw serverError("E_REMOTE_INCOMPATIBLE", `remote oats ${remote.version} at ${target.sshHost} does not advertise the \`oats session\` commands (kernels from ${SESSION_REMOTE_VERSION} do); upgrade it there, or attach with ssh -t ${target.sshHost} tmux attach -t oats`);
606
+ }
607
+ return remote;
608
+ }
609
+
610
+ export function inspectRemote(serverId, { instance, home } = {}, io = {}) {
611
+ const route = resolveRoute(serverId, { instance, home }, "session inspect");
612
+ requireSessionRemote(route.target, io);
613
+ const { envelope, stderr } = runRemote(route.target, ["session", "inspect", "--home", route.home, "--json"], io);
614
+ return { envelope: envelope.ok ? { ...envelope, result: { ...envelope.result, server: serverId, instance: instance || route.snapshot?.instance, home: route.home } } : envelope, stderr, route };
615
+ }
616
+
617
+ export function attachArgv(serverId, { instance, home } = {}, io = {}) {
618
+ const { target, home: remoteHome } = resolveRoute(serverId, { instance, home }, "session attach");
619
+ if (!io.skipVersionCheck) requireSessionRemote(target, io);
620
+ const [bin, ...rest] = sshArgv(target, ["session", "attach", "--home", remoteHome]);
621
+ // -t: allocate a PTY; placed before `--` with the other ssh options.
622
+ return { argv: [bin, "-t", ...rest], target, home: remoteHome };
623
+ }