@awebai/oats 0.30.2 → 0.31.0

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/lib/servers.mjs CHANGED
@@ -18,9 +18,9 @@
18
18
 
19
19
  import { execFileSync } from "node:child_process";
20
20
  import { createHash } from "node:crypto";
21
- import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
21
+ import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
22
22
  import { homedir } from "node:os";
23
- import { join, resolve } from "node:path";
23
+ import { basename, join, resolve } from "node:path";
24
24
  import { parseStrictJson } from "./canonical-json.mjs";
25
25
  import { noteRuntimeName } from "./deprecation.mjs";
26
26
 
@@ -30,8 +30,66 @@ export const REMOTE_SNAPSHOT_DIR = () => join(OATS_HOME_DIR(), "remote");
30
30
 
31
31
  /** Every ssh invocation is non-interactive: a host that needs a password or a
32
32
  * first-time key confirmation fails fast with ssh's own message, instead of
33
- * a lifecycle call hanging on a prompt nobody will answer. */
34
- const SSH_OPTS = ["-o", "BatchMode=yes", "-o", "ConnectTimeout=15"];
33
+ * a lifecycle call hanging on a prompt nobody will answer. Keepalives end a
34
+ * call (an attached viewer included) whose link died without a reset: three
35
+ * unanswered 15 s probes, not a hang until TCP gives up. */
36
+ const SSH_OPTS = ["-o", "BatchMode=yes", "-o", "ConnectTimeout=15", "-o", "ServerAliveInterval=15", "-o", "ServerAliveCountMax=3"];
37
+
38
+ /** Every routed call to one host shares one per-user control master: the
39
+ * probe, the command and an attached viewer ride one authenticated
40
+ * connection, kept 60 s after the last client leaves. Explicit -o wins over
41
+ * a user's own Control* settings in ~/.ssh/config. */
42
+ const SSH_CONTROL_DIR = () => join(OATS_HOME_DIR(), "ssh");
43
+ const controlOpts = () => ["-o", "ControlMaster=auto", "-o", `ControlPath=${join(SSH_CONTROL_DIR(), "%C")}`, "-o", "ControlPersist=60"];
44
+ // Without the master, sharing is switched off outright: a user's own
45
+ // ControlMaster/ControlPath in ~/.ssh/config would otherwise still apply.
46
+ const NO_CONTROL_OPTS = ["-o", "ControlPath=none"];
47
+ // ssh binds the master at `<ControlPath>.<16 random chars>` in a sun_path of
48
+ // 104 bytes (macOS; Linux has 108), NUL included, and a path that does not
49
+ // fit is fatal to the call. %C expands to 40 hex characters.
50
+ const SOCKET_PATH_LIMIT = 104;
51
+ const BIND_SUFFIX = 17;
52
+ const controlSocketBytes = () => Buffer.byteLength(join(SSH_CONTROL_DIR(), "x".repeat(40)));
53
+ // ssh reads an -o value as configuration syntax (whitespace splits it,
54
+ // quotes and # are syntax) and then expands %tokens and ${VAR} in the path:
55
+ // only a directory made of characters it takes literally is the path it binds.
56
+ const LITERAL_PATH_RE = /^[A-Za-z0-9._\/+,:@=-]+$/;
57
+
58
+ /** Why the control socket cannot live under the control directory, or null. */
59
+ function controlPathProblem() {
60
+ const dir = SSH_CONTROL_DIR();
61
+ if (!LITERAL_PATH_RE.test(dir)) return `the control directory ${dir} has characters ssh reads as syntax (only letters, digits and . _ / + , : @ = - are taken literally); set an OATS_HOME_DIR (or HOME) without them`;
62
+ if (controlSocketBytes() + BIND_SUFFIX >= SOCKET_PATH_LIMIT) return `the control socket path ${join(dir, "%C")} expands to ${controlSocketBytes()} bytes, and ssh needs it to fit in ${SOCKET_PATH_LIMIT - BIND_SUFFIX - 1} (the ${SOCKET_PATH_LIMIT}-byte socket path limit less ssh's ${BIND_SUFFIX}-byte bind suffix); set a shorter OATS_HOME_DIR (or HOME)`;
63
+ return null;
64
+ }
65
+
66
+ /** Whether this call may use the control master: the socket path is
67
+ * literal to ssh and fits, and the directory exists, private to this user (created 0700; one owned by
68
+ * someone else or writable by others is never handed to ssh). Otherwise the
69
+ * call runs with sharing off, with one warning per process per server:
70
+ * reuse is an optimisation and must never break routing. */
71
+ const controlWarned = new Set();
72
+ function controlUsable(target, io = {}) {
73
+ let problem = controlPathProblem();
74
+ if (!problem) {
75
+ const dir = SSH_CONTROL_DIR();
76
+ try {
77
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
78
+ const st = lstatSync(dir);
79
+ if (!st.isDirectory()) problem = `${dir} is not a directory`;
80
+ else if (typeof process.getuid === "function" && st.uid !== process.getuid()) problem = `${dir} is owned by another user`;
81
+ else if (st.mode & 0o022) problem = `${dir} is writable by group or others (mode ${(st.mode & 0o777).toString(8)}); make it private with chmod 700`;
82
+ } catch (e) { problem = `${dir} cannot be created: ${e.message}`; }
83
+ }
84
+ if (!problem) return true;
85
+ const key = io.serverId ?? target.sshHost;
86
+ if (!controlWarned.has(key)) {
87
+ controlWarned.add(key);
88
+ const warn = io.warn || ((m) => process.stderr.write(`oats: warning: ${m}\n`));
89
+ warn(`ssh to ${key} runs without connection reuse: ${problem}`);
90
+ }
91
+ return false;
92
+ }
35
93
 
36
94
  const ID_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
37
95
  const SSH_HOST_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; // an OpenSSH alias or host name, never a user@ or option
@@ -55,28 +113,43 @@ export function readServers() {
55
113
  if (!servers || typeof servers !== "object" || Array.isArray(servers)) {
56
114
  throw serverError("E_SERVERS_UNREADABLE", `${file} must be { "servers": { <id>: {...} } }`);
57
115
  }
58
- for (const [id, s] of Object.entries(servers)) validateServer(id, s);
59
- return servers;
116
+ const out = {};
117
+ for (const [id, s] of Object.entries(servers)) { out[id] = withoutHerdrPath(s); validateServer(id, out[id]); }
118
+ return out;
60
119
  }
61
120
 
62
121
  export function writeServers(servers) {
63
122
  const file = SERVERS_FILE();
123
+ // A registration whose target changes (server add --replace) is probed afresh.
124
+ let prior = {};
125
+ try { prior = readServers(); } catch { prior = {}; }
126
+ for (const [id, s] of Object.entries(prior)) {
127
+ if (!servers[id] || JSON.stringify(targetOf(servers[id])) !== JSON.stringify(targetOf(s))) dropRemoteProbes(id);
128
+ }
64
129
  mkdirSync(OATS_HOME_DIR(), { recursive: true });
65
130
  writeFileSync(file, JSON.stringify({ servers }, null, 2) + "\n", { mode: 0o600 });
66
131
  }
67
132
 
133
+ /** A registration or saved route written before 0.31.0 may record `herdrPath` (Herdr was removed
134
+ * then): it is read and dropped, never written, printed or passed. */
135
+ function withoutHerdrPath(entry) {
136
+ if (!entry || typeof entry !== "object" || Array.isArray(entry) || !Object.hasOwn(entry, "herdrPath")) return entry;
137
+ const { herdrPath: _ignored, ...rest } = entry;
138
+ return rest;
139
+ }
140
+
68
141
  /** A registration names WHERE and HOW, never WITH WHAT credentials. */
69
142
  export function validateServer(id, s) {
70
143
  if (!ID_RE.test(String(id))) throw serverError("E_SERVER_INVALID", `server id ${JSON.stringify(id)} must be lowercase letters, digits and dashes`);
71
144
  if (!s || typeof s !== "object" || Array.isArray(s)) throw serverError("E_SERVER_INVALID", `server ${id}: entry must be an object`);
72
145
  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)`);
73
146
  if (typeof s.workspace !== "string" || !s.workspace.startsWith("/")) throw serverError("E_SERVER_INVALID", `server ${id}: workspace must be an absolute path on the server`);
74
- for (const k of ["oatsPath", "herdrPath", "label", "path"]) {
147
+ for (const k of ["oatsPath", "label", "path"]) {
75
148
  if (s[k] !== undefined && s[k] !== null && typeof s[k] !== "string") throw serverError("E_SERVER_INVALID", `server ${id}: ${k} must be a string`);
76
149
  }
77
150
  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`);
78
151
  for (const k of Object.keys(s)) {
79
- 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)`);
152
+ if (!["label", "sshHost", "workspace", "oatsPath", "path"].includes(k)) throw serverError("E_SERVER_INVALID", `server ${id}: unknown field ${JSON.stringify(k)} (a registration holds label, sshHost, workspace, oatsPath, path and nothing else — never keys or passwords)`);
80
153
  }
81
154
  return true;
82
155
  }
@@ -107,7 +180,6 @@ export function targetOf(server) {
107
180
  sshHost: server.sshHost,
108
181
  workspace: server.workspace,
109
182
  oatsPath: server.oatsPath || "oats",
110
- ...(server.herdrPath ? { herdrPath: server.herdrPath } : {}),
111
183
  ...(server.path ? { path: server.path } : {}),
112
184
  };
113
185
  }
@@ -124,7 +196,7 @@ export function targetKey(target) {
124
196
  return createHash("sha256").update(`${target.sshHost}\0${target.workspace}`).digest("hex").slice(0, 12);
125
197
  }
126
198
 
127
- export function sshArgv(target, oatsArgs, { cwd } = {}) {
199
+ export function sshArgv(target, oatsArgs, { cwd, control = false } = {}) {
128
200
  // A non-interactive ssh command runs in the login shell's minimal PATH,
129
201
  // which rarely includes user-local tool directories (~/.local/bin, where
130
202
  // claude and pi commonly live). A registration may name directories to
@@ -136,8 +208,10 @@ export function sshArgv(target, oatsArgs, { cwd } = {}) {
136
208
  const prefix = dirs.length ? `PATH=${dirs.join(":")}:"$PATH" ` : "";
137
209
  // An optional working directory for commands that read their instance
138
210
  // from cwd (oats okf harvest): a quoted cd, never a path from the caller.
211
+ // Sharing is on only when the caller checked the control directory
212
+ // (controlUsable); otherwise ControlPath=none.
139
213
  const cmd = (cwd ? `cd ${remoteQuote(cwd)} && ` : "") + prefix + [target.oatsPath, ...oatsArgs].map(remoteQuote).join(" ");
140
- return ["ssh", ...SSH_OPTS, "--", target.sshHost, cmd];
214
+ return ["ssh", ...SSH_OPTS, ...(control && !controlPathProblem() ? controlOpts() : NO_CONTROL_OPTS), "--", target.sshHost, cmd];
141
215
  }
142
216
 
143
217
  // ------------------------------------------------------------------ running
@@ -182,18 +256,23 @@ export function parseRemoteEnvelope(stdout) {
182
256
  * refused key, unknown host) surface as E_SSH with ssh's stderr. */
183
257
  export function runRemote(target, oatsArgs, io = {}) {
184
258
  const exec = io.execFileSync || execFileSync;
185
- const [bin, ...argv] = sshArgv(target, oatsArgs, { cwd: io.cwd });
259
+ const [bin, ...argv] = sshArgv(target, oatsArgs, { cwd: io.cwd, control: controlUsable(target, io) });
186
260
  let stdout = "";
187
261
  let stderr = "";
188
262
  let status = 0;
263
+ let started = true;
189
264
  try {
190
265
  // `io.input` (a Buffer) streams to the remote command's stdin: the only
191
266
  // way bytes reach a host, as a quoted argument never could.
192
267
  stdout = exec(bin, argv, { encoding: "utf8", stdio: [io.input === undefined ? "ignore" : "pipe", "pipe", "pipe"], ...(io.input === undefined ? {} : { input: io.input }), maxBuffer: 16 * 1024 * 1024, timeout: io.timeoutMs || 300000 });
193
268
  } catch (e) {
194
269
  stdout = String(e.stdout || "");
195
- stderr = String(e.stderr || e.message || "");
270
+ // The process's own stderr when it ran (possibly empty); the error's
271
+ // message only when there was no process to speak (ssh not found).
272
+ stderr = String(e.stderr ?? e.message ?? "");
196
273
  status = typeof e.status === "number" ? e.status : 255;
274
+ // Neither an exit status nor a signal: ssh never started (not installed, not executable).
275
+ started = typeof e.status === "number" || !!e.signal;
197
276
  }
198
277
  if (String(stdout).trim()) {
199
278
  const envelope = parseRemoteEnvelope(stdout);
@@ -207,22 +286,51 @@ export function runRemote(target, oatsArgs, io = {}) {
207
286
  }
208
287
  // ssh exits 255 for its own failures; the remote command's exit code is
209
288
  // relayed otherwise. Either way with no envelope there is nothing to trust.
210
- 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)"}`);
289
+ // An ssh that never started is still E_SSH to callers, with `details: { sshStarted: false }` (it reaches
290
+ // the --json envelope): there is no link to come back. No details means ssh started.
291
+ const error = serverError(status === 255 ? "E_SSH" : "E_REMOTE_ENVELOPE", `${status === 255 ? `ssh to ${target.sshHost} failed` : `remote oats exited ${status} with no envelope`}: ${stderr.trim().slice(0, 400) || "(no output)"}`);
292
+ if (status === 255 && !started) error.details = { sshStarted: false };
293
+ throw error;
211
294
  }
212
295
 
213
296
  /** Version and envelope compatibility, checked BEFORE any mutation. The
214
297
  * remote must answer `version --json` with the Desktop API v1 probe payload
215
298
  * and a kernel the local side knows how to talk to. */
216
299
  export function checkRemote(target, io = {}) {
300
+ const probes = probesOf(io);
301
+ const key = probeKey(io.serverId, target);
302
+ const remote = probes.get(key) || probeRemote(target, io);
303
+ probes.set(key, remote);
304
+ const min = io.minVersion || MIN_REMOTE_VERSION;
305
+ if (compareSemver(remote.version, min) < 0) {
306
+ throw serverError("E_REMOTE_INCOMPATIBLE", `remote oats ${remote.version} at ${target.sshHost} is older than the minimum ${min} this kernel routes to; upgrade it there`);
307
+ }
308
+ return remote;
309
+ }
310
+
311
+ /** The version probe is asked once per process per server id and target: a
312
+ * command that probes, resolves and then acts asks the host once. The cache
313
+ * belongs to the transport (the process's ssh, or a caller's injected one)
314
+ * and holds successful answers only. */
315
+ const probeCaches = new Map();
316
+ const probesOf = (io) => {
317
+ const exec = io.execFileSync || execFileSync;
318
+ if (!probeCaches.has(exec)) probeCaches.set(exec, new Map());
319
+ return probeCaches.get(exec);
320
+ };
321
+ const probeKey = (serverId, target) => JSON.stringify([serverId ?? null, target.sshHost, target.workspace, target.oatsPath, target.path ?? null]);
322
+
323
+ /** Forget every probe of a server id: its registration changed. */
324
+ export function dropRemoteProbes(serverId) {
325
+ for (const probes of probeCaches.values()) for (const key of probes.keys()) if (JSON.parse(key)[0] === serverId) probes.delete(key);
326
+ }
327
+
328
+ function probeRemote(target, io) {
217
329
  const { envelope } = runRemote(target, ["version", "--json"], { ...io, timeoutMs: 60000 });
218
330
  const probe = envelope.result || {};
219
331
  if (probe.desktopApi !== 1 || typeof probe.version !== "string") {
220
332
  throw serverError("E_REMOTE_INCOMPATIBLE", `remote oats at ${target.sshHost} answered an unknown version payload: ${JSON.stringify(probe).slice(0, 200)}`);
221
333
  }
222
- const min = io.minVersion || MIN_REMOTE_VERSION;
223
- if (compareSemver(probe.version, min) < 0) {
224
- 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`);
225
- }
226
334
  // What the remote kernel advertises it can launch. A probe without these
227
335
  // fields is a 0.22.1-class kernel: pi and claude only, no session backend
228
336
  // choice, no launch options; nothing newer may be requested of it. The
@@ -239,6 +347,7 @@ export function checkRemote(target, io = {}) {
239
347
  features: list("features", []),
240
348
  operationsApi: [1, 2].includes(probe.operationsApi) ? probe.operationsApi : null,
241
349
  scheduleApi: [1, 2].includes(probe.scheduleApi) ? probe.scheduleApi : null,
350
+ ...Object.fromEntries(["readinessApi", "eventsApi", "instanceGitApi", "lifecycleApi"].map((k) => [k, Number.isInteger(probe[k]) ? probe[k] : null])),
242
351
  advertised: Array.isArray(probe[harnessKey]),
243
352
  };
244
353
  }
@@ -339,11 +448,18 @@ export function writeSnapshot(serverId, target, remote, spawnResult) {
339
448
  export function readSnapshot(serverId, instance) {
340
449
  const p = snapshotPath(serverId, instance);
341
450
  if (!existsSync(p)) return undefined;
451
+ let snap;
342
452
  try {
343
- return JSON.parse(readFileSync(p, "utf8"));
453
+ snap = JSON.parse(readFileSync(p, "utf8"));
344
454
  } catch (e) {
345
455
  throw serverError("E_SNAPSHOT_UNREADABLE", `${p}: ${e.message}`);
346
456
  }
457
+ return savedRoute(snap);
458
+ }
459
+
460
+ /** A saved route as read: its frozen target without any recorded `herdrPath`. */
461
+ function savedRoute(snap) {
462
+ return snap && typeof snap === "object" && !Array.isArray(snap) && snap.target ? { ...snap, target: withoutHerdrPath(snap.target) } : snap;
347
463
  }
348
464
 
349
465
  export function removeSnapshot(serverId, instance) {
@@ -368,7 +484,7 @@ export function listSnapshots(serverId) {
368
484
  for (const f of readdirSync(dir)) {
369
485
  if (!f.endsWith(".json")) continue;
370
486
  try {
371
- out.push(JSON.parse(readFileSync(join(dir, f), "utf8")));
487
+ out.push(savedRoute(JSON.parse(readFileSync(join(dir, f), "utf8"))));
372
488
  } catch {
373
489
  out.push({ serverId, instance: f.replace(/\.json$/, ""), unreadable: true });
374
490
  }
@@ -386,6 +502,9 @@ export function listSnapshots(serverId) {
386
502
  * when the remote kernel reports the home gone;
387
503
  * - status: pulled from the remote kernel, never cached. */
388
504
  export function routeCommand(serverId, cmd, oatsArgs, io = {}) {
505
+ io = { ...io, serverId };
506
+ // The host's reads and plans resolve their own route: a saved one needs no registration.
507
+ if (cmd === "readiness" || cmd === "instance" || (cmd === "retire" && oatsArgs.includes("--plan"))) return routeHostSurface(serverId, cmd, oatsArgs, io);
389
508
  // A retirement may outlive its registration: the snapshot taken at spawn
390
509
  // is the route, and it is consulted before the registry is required.
391
510
  const instanceArg = oatsArgs.find((a) => !a.startsWith("--"));
@@ -451,8 +570,20 @@ export function routeCommand(serverId, cmd, oatsArgs, io = {}) {
451
570
  const hi = oatsArgs.indexOf("--home");
452
571
  const explicitHome = hi >= 0 && oatsArgs[hi + 1] && !oatsArgs[hi + 1].startsWith("--") ? oatsArgs[hi + 1] : undefined;
453
572
  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`);
573
+ // A guarded apply carries the revision of a plan: the host must speak the lifecycle contract.
574
+ const guarded = oatsArgs.includes("--plan-revision") || oatsArgs.includes("--idempotency-key");
575
+ if (guarded) requireSurface(remote, target, HOST_SURFACES["retire plan"]);
454
576
  const remoteRetiresByHome = Array.isArray(remote?.features) && remote.features.includes("retire-home");
455
- const wantHome = explicitHome || snap?.home;
577
+ // No saved route and no --home: the name resolves through the host's
578
+ // roster to its one home, so a same-named twin is never the one retired.
579
+ // A guarded apply whose name the host no longer lists goes as is: the
580
+ // host replays the receipt recorded under its key, or refuses.
581
+ let hostHome;
582
+ if (name && !explicitHome && !snap?.home) {
583
+ try { hostHome = instanceOnHost(serverId, name, { ...io, server }).home; }
584
+ catch (e) { if (!(guarded && e.code === "E_SNAPSHOT_UNKNOWN")) throw e; }
585
+ }
586
+ const wantHome = explicitHome || snap?.home || hostHome;
456
587
  if (wantHome && !remoteRetiresByHome) {
457
588
  // An older kernel ignores --home and retires by name, first match. It
458
589
  // is safe only when the name is unique there and is the saved home;
@@ -509,7 +640,7 @@ export function routeCommand(serverId, cmd, oatsArgs, io = {}) {
509
640
  const valueOf = (name) => { const i = args.indexOf(name); return i >= 0 && args[i + 1] && !args[i + 1].startsWith("--") ? args[i + 1] : undefined; };
510
641
  let route;
511
642
  if (valueOf("--home") !== undefined || valueOf("--instance") !== undefined) {
512
- route = resolveRoute(serverId, { instance: valueOf("--instance"), home: valueOf("--home") }, cmd);
643
+ route = resolveRoute(serverId, { instance: valueOf("--instance"), home: valueOf("--home") }, cmd, io);
513
644
  target = route.target;
514
645
  const ii = args.indexOf("--instance");
515
646
  if (ii >= 0) { args.splice(ii, 2); if (valueOf("--home") === undefined) args.push("--home", route.home); }
@@ -522,7 +653,52 @@ export function routeCommand(serverId, cmd, oatsArgs, io = {}) {
522
653
  const { envelope, stderr } = runRemote(target, json([cmd, ...scoped]), io);
523
654
  return { envelope: envelope.result && typeof envelope.result === "object" ? { ...envelope, result: { ...envelope.result, server: serverId, ...(route ? { route: { home: route.home, instance: route.snapshot?.instance || null, frozen: !!route.snapshot } } : {}) } } : envelope, stderr };
524
655
  }
525
- throw serverError("E_USAGE", `--server routes spawn, retire, status, session, okf harvest, schedule, inspect and operation only (not ${cmd})`);
656
+ throw serverError("E_USAGE", `--server routes spawn, retire, status, session, okf harvest, schedule, inspect, operation, readiness and instance only (not ${cmd})`);
657
+ }
658
+
659
+ /** The Desktop's per-instance reads and lifecycle plans, as the host's kernel
660
+ * answers them: the feature and API number the host must advertise first. */
661
+ const HOST_SURFACES = {
662
+ readiness: { feature: "readiness", api: "readinessApi", version: 2 },
663
+ "instance events": { feature: "instance-events-2", api: "eventsApi", version: 2 },
664
+ "instance git": { feature: "instance-git", api: "instanceGitApi", version: 1 },
665
+ "instance diff": { feature: "instance-git", api: "instanceGitApi", version: 1 },
666
+ "instance stop": { feature: "lifecycle-plans", api: "lifecycleApi", version: 1 },
667
+ "retire plan": { feature: "lifecycle-plans", api: "lifecycleApi", version: 1 },
668
+ };
669
+
670
+ function requireSurface(remote, target, surface) {
671
+ if (!remote.features.includes(surface.feature) || remote[surface.api] !== surface.version) {
672
+ throw serverError("E_REMOTE_INCOMPATIBLE", `remote oats ${remote.version} at ${target.sshHost} does not advertise ${surface.feature} (${surface.api} ${surface.version}); upgrade it there; nothing was sent`);
673
+ }
674
+ }
675
+
676
+ /** `readiness`, `instance events|git|diff|stop` and `retire --plan` on the
677
+ * host: the instance is addressed as every routed session command addresses
678
+ * it (its name through a saved route or the host's roster, or an exact
679
+ * --home), the scope is an explicit --dir on the host or the registered
680
+ * workspace (readiness of a home is its own context), and the host's
681
+ * envelope is relayed unchanged. */
682
+ function routeHostSurface(serverId, cmd, oatsArgs, io) {
683
+ const key = cmd === "instance" ? `instance ${oatsArgs[0]}` : cmd === "retire" ? "retire plan" : cmd;
684
+ const surface = HOST_SURFACES[key];
685
+ if (!surface) throw serverError("E_USAGE", `--server routes instance events, git, diff and stop only (not ${key})`);
686
+ const args = [...oatsArgs];
687
+ const valueOf = (flag) => { const i = args.indexOf(flag); return i >= 0 && args[i + 1] !== undefined && !args[i + 1].startsWith("--") ? args[i + 1] : undefined; };
688
+ const name = cmd === "readiness" ? undefined : args[cmd === "instance" ? 1 : 0];
689
+ if (cmd !== "readiness" && (!name || name.startsWith("--"))) throw serverError("E_BAD_ARGS", `${key} --server needs the instance name`);
690
+ const home = valueOf("--home");
691
+ if (args.includes("--home") && home === undefined) throw serverError("E_BAD_ARGS", "--home needs an absolute instance home");
692
+ let target;
693
+ if (name !== undefined || home !== undefined) {
694
+ const route = resolveRoute(serverId, { instance: name, home }, key, io);
695
+ target = route.target;
696
+ if (home === undefined) args.push("--home", route.home);
697
+ } else target = targetOf(io.server || getServer(serverId));
698
+ requireSurface(checkRemote(target, io), target, surface);
699
+ if (!args.includes("--dir") && !(cmd === "readiness" && home !== undefined)) args.push("--dir", target.workspace);
700
+ const { envelope, stderr } = runRemote(target, [cmd, ...args, "--json"], io);
701
+ return { envelope, stderr };
526
702
  }
527
703
 
528
704
  // ------------------------------------------------------------------- roster
@@ -546,6 +722,12 @@ function listSnapshotServers() {
546
722
  * false) and its instances from the snapshots (running null when the probe
547
723
  * fails). Remote state is pulled, never cached; the saved route stays the
548
724
  * action authority. */
725
+ /** The facts a remote row relays from the host's `status --json` row, as the
726
+ * host reported them; a fact the host does not supply is null, never
727
+ * derived here. */
728
+ export const REMOTE_ROW_FACTS = ["identity", "identityAddress", "teams", "startedAt", "createdAt", "model", "runtimeState", "parentInstance", "siblingInstance", "relation", "relativeTo", "spawnOrigin"];
729
+ const rowFacts = (i = {}) => Object.fromEntries(REMOTE_ROW_FACTS.map((k) => [k, i[k] ?? null]));
730
+
549
731
  export function rosterGroups({ server, io = {} } = {}) {
550
732
  const started = Date.now();
551
733
  if (server && !ID_RE.test(server)) throw serverError("E_BAD_ARGS", `server id ${JSON.stringify(server)} is not a valid id`);
@@ -574,7 +756,7 @@ export function rosterGroups({ server, io = {} } = {}) {
574
756
  skipped++;
575
757
  g.probe = { ok: false, error: { code: "E_ROSTER_BUDGET", message: `not probed: the ${budgetMs} ms roster budget was used up by earlier targets` } };
576
758
  } else {
577
- try { status = runRemote(g.target, ["status", "--json", "--dir", g.target.workspace], { ...io, timeoutMs: Math.min(perTargetTimeoutMs, remaining) }).envelope; }
759
+ try { status = runRemote(g.target, ["status", "--json", "--dir", g.target.workspace], { ...io, serverId: g.server, timeoutMs: Math.min(perTargetTimeoutMs, remaining) }).envelope; }
578
760
  catch (e) { g.probe = { ok: false, error: { code: e.code || "E_SSH", message: e.message } }; }
579
761
  }
580
762
  if (status && !status.ok) g.probe = { ok: false, error: status.error || { code: "E_REMOTE", message: "status failed" } };
@@ -596,10 +778,13 @@ export function rosterGroups({ server, io = {} } = {}) {
596
778
  const snap = routeOf(i);
597
779
  g.instances.push({
598
780
  server: g.server, instance: i.instance, agent: a.name, home: i.home || snap?.home, agentsRoot: status.result.root,
599
- harness: i.harness || i.runtime || null, backend: i.sessionTarget?.backend || (i.tmux ? "tmux" : null),
781
+ harness: i.harness || i.runtime || null, backend: i.tmux ? "tmux" : null,
600
782
  ...(i.sessionTarget ? { sessionTarget: i.sessionTarget } : {}), ...(i.tmux ? { tmux: i.tmux } : {}),
601
783
  running: typeof i.running === "boolean" ? i.running : null, ...(i.runtimeError ? { runtimeError: i.runtimeError } : {}),
602
- retirePending: !!i.retirePending, rollbackIncomplete: !!i.rollbackIncomplete, savedRoute: !!snap, missingRemotely: false,
784
+ ...rowFacts(i),
785
+ // Every row the host reports is addressed by its home, saved
786
+ // route or not; savedRoute says only that it was spawned from here.
787
+ retirePending: !!i.retirePending, rollbackIncomplete: !!i.rollbackIncomplete, savedRoute: !!snap, addressable: true, missingRemotely: false,
603
788
  });
604
789
  if (snap) bySnapshot.delete(i.instance);
605
790
  }
@@ -609,7 +794,8 @@ export function rosterGroups({ server, io = {} } = {}) {
609
794
  // instance is gone there (retired, or its home removed) and the route is
610
795
  // stale; with a failed probe nothing is known. Same keys as remote rows.
611
796
  for (const snap of bySnapshot.values()) {
612
- g.instances.push({ server: g.server, instance: snap.instance, agent: snap.agent, home: snap.home, agentsRoot: snap.agentsRoot, harness: snap.harness || snap.runtime || null, backend: null, running: null, retirePending: false, rollbackIncomplete: false, savedRoute: true, missingRemotely: g.probe?.ok === true });
797
+ // Addressable through its saved route unless the host said it is gone.
798
+ g.instances.push({ server: g.server, instance: snap.instance, agent: snap.agent, home: snap.home, agentsRoot: snap.agentsRoot, harness: snap.harness || snap.runtime || null, backend: null, running: null, ...rowFacts(), retirePending: false, rollbackIncomplete: false, savedRoute: true, addressable: g.probe?.ok !== true, missingRemotely: g.probe?.ok === true });
613
799
  }
614
800
  delete g._snapshots;
615
801
  }
@@ -624,21 +810,37 @@ export function rosterGroups({ server, io = {} } = {}) {
624
810
  * when an instance name is given (spawned from here), else the registry's
625
811
  * target with a caller-supplied absolute remote home. Nothing about the
626
812
  * remote binary or path ever comes from the caller. */
627
- export function resolveRoute(serverId, { instance, home } = {}, what = "session") {
813
+ export function resolveRoute(serverId, { instance, home } = {}, what = "session", io = {}) {
628
814
  // The home is the identity (same-named twins on one host are different
629
- // instances); a name alone resolves through its saved route. With a home,
630
- // the saved route that owns it supplies the target (a registration edited
631
- // later never re-routes a viewer); a name AND a home must agree.
815
+ // instances). A name resolves through its saved route, else through the
816
+ // host's own roster. With a home, the saved route that owns it supplies the
817
+ // target (a registration edited later never re-routes a viewer); a home no
818
+ // saved route owns is addressed through the registration. A name AND a home
819
+ // must agree.
632
820
  let snap = instance ? readSnapshot(serverId, instance) : undefined;
633
- 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})`);
634
821
  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})`);
635
822
  if (home && !snap) snap = listSnapshots(serverId).find((s) => s.home && resolve(s.home) === resolve(home));
636
- const target = snap?.target || targetOf(getServer(serverId));
637
- const remoteHome = home || snap?.home;
638
- if (!remoteHome || !String(remoteHome).startsWith("/")) throw serverError("E_BAD_ARGS", `${what} --server needs --instance <name> (spawned from here) or --home </absolute/remote/home>`);
823
+ if (instance && snap && snap.instance !== instance) throw serverError("E_HOME_MISMATCH", `--home ${home} is the saved route of ${snap.instance} on ${serverId}, not of ${instance}`);
824
+ if (instance && !snap && home && basename(home) !== instance) throw serverError("E_HOME_MISMATCH", `--home ${home} is not the home of instance ${instance}`);
825
+ const remoteHome = home || snap?.home || (instance ? instanceOnHost(serverId, instance, io).home : undefined);
826
+ const target = snap?.target || targetOf(io.server || getServer(serverId));
827
+ if (!remoteHome || !String(remoteHome).startsWith("/")) throw serverError("E_BAD_ARGS", `${what} --server needs --instance <name> or --home </absolute/remote/home>`);
639
828
  return { target, home: remoteHome, snapshot: snap };
640
829
  }
641
830
 
831
+ /** An instance with no saved route here, by name, through the host's own
832
+ * roster (one `status --json` in the registered workspace): the one home of
833
+ * that name there, or E_AMBIGUOUS naming every home, or E_SNAPSHOT_UNKNOWN. */
834
+ export function instanceOnHost(serverId, name, io = {}) {
835
+ const target = targetOf(io.server || getServer(serverId));
836
+ const status = runRemote(target, ["status", "--json", "--dir", target.workspace], { ...io, serverId, input: undefined }).envelope;
837
+ if (!status.ok) throw serverError(status.error?.code || "E_REMOTE", `cannot resolve ${name} on server ${serverId}: ${status.error?.message || "status failed"}`);
838
+ const candidates = (status.result?.agents || []).flatMap((a) => (a.instances || []).filter((i) => i.instance === name).map((i) => ({ agent: a.name, home: i.home })));
839
+ if (!candidates.length) throw serverError("E_SNAPSHOT_UNKNOWN", `no instance ${JSON.stringify(name)} on server ${serverId}: neither a saved route here nor its roster names one (oats server roster --server ${serverId})`);
840
+ if (candidates.length > 1) throw Object.assign(serverError("E_AMBIGUOUS", `instance ${JSON.stringify(name)} names ${candidates.length} homes on server ${serverId}: ${candidates.map((c) => `${c.home} (${c.agent})`).join(", ")}; pass --home <one of them>`), { details: { candidates } });
841
+ return { target, home: candidates[0].home };
842
+ }
843
+
642
844
  /** `session inspect` on the execution host for a remote instance: the same
643
845
  * route resolution as attach, the standard envelope relayed as is (ok or
644
846
  * not), never an ACK of anything. */
@@ -646,17 +848,31 @@ export function resolveRoute(serverId, { instance, home } = {}, what = "session"
646
848
  * directly: a kernel that routes session itself also serves it. A silent
647
849
  * probe (before 0.22.2) or one without session in the list is refused
648
850
  * before any session command is sent. */
649
- function requireSessionRemote(target, io) {
851
+ function requireSessionRemote(target, io, home) {
650
852
  const remote = checkRemote(target, io);
651
853
  if (!remote.remote.includes("session")) {
652
- 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`);
854
+ 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 ${recordedTmuxTarget(target, io, home)}`);
653
855
  }
654
856
  return remote;
655
857
  }
656
858
 
859
+ /** Where a kernel without `oats session` put the instance's window: the tmux
860
+ * session and window its roster records for that home, else its default
861
+ * session (pi-agents, the default of every kernel before 0.31). */
862
+ const OLD_KERNEL_TMUX_SESSION = "pi-agents";
863
+ function recordedTmuxTarget(target, io, home) {
864
+ try {
865
+ const status = runRemote(target, ["status", "--json", "--dir", target.workspace], { ...io, input: undefined }).envelope;
866
+ const row = (status.ok ? status.result?.agents || [] : []).flatMap((a) => a.instances || []).find((i) => i.home && home && resolve(i.home) === resolve(home));
867
+ if (row?.tmux?.session) return row.tmux.window ? `${row.tmux.session}:${row.tmux.window}` : row.tmux.session;
868
+ } catch { /* the hint falls back to the default session */ }
869
+ return OLD_KERNEL_TMUX_SESSION;
870
+ }
871
+
657
872
  export function inspectRemote(serverId, { instance, home } = {}, io = {}) {
658
- const route = resolveRoute(serverId, { instance, home }, "session inspect");
659
- requireSessionRemote(route.target, io);
873
+ io = { ...io, serverId };
874
+ const route = resolveRoute(serverId, { instance, home }, "session inspect", io);
875
+ requireSessionRemote(route.target, io, route.home);
660
876
  const { envelope, stderr } = runRemote(route.target, ["session", "inspect", "--home", route.home, "--json"], io);
661
877
  return { envelope: envelope.ok ? { ...envelope, result: { ...envelope.result, server: serverId, instance: instance || route.snapshot?.instance, home: route.home } } : envelope, stderr, route };
662
878
  }
@@ -705,10 +921,11 @@ function requireRemoteFeature(remote, target, feature) {
705
921
  }
706
922
 
707
923
  function launchRemoteSession(serverId, action, choices, io) {
924
+ io = { ...io, serverId };
708
925
  const { instance, home, launchConfig, harness, yolo } = choices;
709
926
  const choiceArgs = remoteLaunchArgs(choices);
710
- const route = resolveRoute(serverId, { instance, home }, `session ${action}`);
711
- const remote = requireSessionRemote(route.target, io);
927
+ const route = resolveRoute(serverId, { instance, home }, `session ${action}`, io);
928
+ const remote = requireSessionRemote(route.target, io, route.home);
712
929
  if (!remote.features.includes("session-start")) {
713
930
  throw serverError("E_REMOTE_INCOMPATIBLE", `remote oats ${remote.version} at ${route.target.sshHost} does not advertise session-start (kernels from ${SESSION_START_REMOTE_VERSION} do); upgrade it there, or start the instance on that host`);
714
931
  }
@@ -733,6 +950,7 @@ function hostLaunchDefinition(remote, definition) {
733
950
  return (remote?.features || []).includes("harness") ? { ...rest, harness: value } : { ...rest, runtime: value };
734
951
  }
735
952
  export function launchConfigRemote(serverId, options = {}, io = {}) {
953
+ io = { ...io, serverId };
736
954
  const { action, name, context, instance, home, soul, agentsRoot, definition, keepEnv } = options;
737
955
  if (!["list", "set", "remove", "preview"].includes(action)) throw serverError("E_BAD_ARGS", "unknown launch configuration action");
738
956
  const write = action === "set" || action === "remove";
@@ -764,7 +982,7 @@ export function launchConfigRemote(serverId, options = {}, io = {}) {
764
982
  if (soul !== undefined) add("--soul", soul);
765
983
  if (agentsRoot !== undefined) add("--agents-root", agentsRoot, true);
766
984
  if (action === "preview") args.push(...remoteLaunchArgs(options));
767
- const route = homeSelected ? resolveRoute(serverId, { instance, home }, "launch-config") : undefined;
985
+ const route = homeSelected ? resolveRoute(serverId, { instance, home }, "launch-config", { ...io, input: undefined }) : undefined;
768
986
  const target = route?.target || targetOf(io.server || getServer(serverId));
769
987
  if (route) add("--home", route.home, true);
770
988
  else if (context === undefined) args.push("--dir", target.workspace);
@@ -784,6 +1002,7 @@ const CAPTURED_SCHEDULE_KEYS = ["definitionVersion", "recurrencePolicy", "execut
784
1002
  * before any remote mutation when the remote kernel does not advertise
785
1003
  * the schedule feature. The envelope is relayed as is. */
786
1004
  export function scheduleRemote(serverId, oatsArgs, io = {}) {
1005
+ io = { ...io, serverId };
787
1006
  const server = io.server || getServer(serverId);
788
1007
  const target = targetOf(server);
789
1008
  const remote = checkRemote(target, io);
@@ -814,9 +1033,10 @@ export function scheduleRemote(serverId, oatsArgs, io = {}) {
814
1033
  }
815
1034
 
816
1035
  export function attachArgv(serverId, { instance, home } = {}, io = {}) {
817
- const { target, home: remoteHome } = resolveRoute(serverId, { instance, home }, "session attach");
818
- if (!io.skipVersionCheck) requireSessionRemote(target, io);
819
- const [bin, ...rest] = sshArgv(target, ["session", "attach", "--home", remoteHome]);
1036
+ io = { ...io, serverId };
1037
+ const { target, home: remoteHome } = resolveRoute(serverId, { instance, home }, "session attach", io);
1038
+ if (!io.skipVersionCheck) requireSessionRemote(target, io, remoteHome);
1039
+ const [bin, ...rest] = sshArgv(target, ["session", "attach", "--home", remoteHome], { control: controlUsable(target, io) });
820
1040
  // -t: allocate a PTY; placed before `--` with the other ssh options.
821
1041
  return { argv: [bin, "-t", ...rest], target, home: remoteHome };
822
1042
  }
@@ -2,7 +2,6 @@
2
2
  import { execFileSync } from "node:child_process";
3
3
  import { basename } from "node:path";
4
4
  import { randomUUID } from "node:crypto";
5
- import { inspectHerdr, inputHerdr, herdrCommand } from "./herdr.mjs";
6
5
 
7
6
  const shells = new Set(["sh", "bash", "zsh", "fish", "dash", "ksh", "login"]);
8
7
  function tmux(target, args, { exec = execFileSync, input } = {}) {
@@ -14,16 +13,6 @@ function tmux(target, args, { exec = execFileSync, input } = {}) {
14
13
  });
15
14
  }
16
15
  export function inspectSessionTarget(target, io) {
17
- if (target.backend === "herdr") {
18
- const s = inspectHerdr(target, io);
19
- let state = s.present ? s.status : "stopped";
20
- if (s.present && !s.agent) {
21
- const processes = herdrCommand(target, ["pane", "process-info", "--pane", target.paneId], io).process_info?.foreground_processes;
22
- if (!Array.isArray(processes)) throw new Error("Herdr returned no process information");
23
- if (!processes.length || processes.every((p) => shells.has(p.name))) state = "shell";
24
- }
25
- return { backend: "herdr", present: s.present, state, terminalId: target.terminalId };
26
- }
27
16
  let rows;
28
17
  try {
29
18
  rows = tmux(target, ["list-panes", "-t", `=${target.session}:=${target.window}`, "-F", "#{pane_id}\t#{pane_dead}\t#{pane_current_command}\t#{pane_pid}"], io).trim().split("\n").filter(Boolean);
@@ -63,18 +52,15 @@ export function inspectSessionTarget(target, io) {
63
52
  export function inputSessionTarget(target, text, io) {
64
53
  const state = inspectSessionTarget(target, io);
65
54
  if (!state.present || state.state === "shell") throw new Error(`cannot submit input: session is ${state.state}`);
66
- if (target.backend === "herdr") inputHerdr(target, text, io);
67
- else {
68
- // Bracketed paste preserves multiline input as one user message. No text
69
- // is evaluated by a shell or interpreted as tmux key names.
70
- const buffer = `oats-${randomUUID()}`;
71
- try {
72
- tmux(target, ["load-buffer", "-b", buffer, "-"], { ...io, input: text });
73
- tmux(target, ["paste-buffer", "-p", "-b", buffer, "-t", state.paneId], io);
74
- tmux(target, ["send-keys", "-t", state.paneId, "Enter"], io);
75
- } finally {
76
- try { tmux(target, ["delete-buffer", "-b", buffer], io); } catch { /* already consumed or disconnected */ }
77
- }
55
+ // Bracketed paste preserves multiline input as one user message. No text
56
+ // is evaluated by a shell or interpreted as tmux key names.
57
+ const buffer = `oats-${randomUUID()}`;
58
+ try {
59
+ tmux(target, ["load-buffer", "-b", buffer, "-"], { ...io, input: text });
60
+ tmux(target, ["paste-buffer", "-p", "-b", buffer, "-t", state.paneId], io);
61
+ tmux(target, ["send-keys", "-t", state.paneId, "Enter"], io);
62
+ } finally {
63
+ try { tmux(target, ["delete-buffer", "-b", buffer], io); } catch { /* already consumed or disconnected */ }
78
64
  }
79
65
  return { ...state, submitted: true };
80
66
  }
@@ -5,11 +5,6 @@ import { inspectSessionTarget } from "./session-input.mjs";
5
5
 
6
6
  export function prepareSessionViewer(target, { exec = execFileSync } = {}) {
7
7
  if (!inspectSessionTarget(target, { exec }).present) throw new Error("session no longer exists");
8
- if (target.backend === "herdr") {
9
- const env = { ...process.env, HERDR_SOCKET_PATH: target.socket };
10
- delete env.HERDR_SESSION;
11
- return { binary: target.binary, args: ["terminal", "attach", target.terminalId], env, cleanup() {} };
12
- }
13
8
  // UTF-8 mode (-u) like the shared session helper: a service without a
14
9
  // UTF-8 locale (launchd) otherwise mangles tmux output.
15
10
  const run = (args) => exec("tmux", ["-u", "-S", target.socket, ...args], {