@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/bin/oats.mjs +111 -44
- package/docs/configuration.md +21 -1
- package/docs/desktop-cli-api.md +120 -14
- package/docs/desktop-instance-start.md +3 -1
- package/docs/desktop.md +20 -0
- package/docs/execution-targets.md +53 -30
- package/docs/implementation.md +1 -1
- package/docs/integrations.md +1 -1
- package/docs/oats-local.schema.json +8 -0
- package/docs/official-catalog.md +1 -1
- package/docs/packages.md +2 -2
- package/docs/release-notes/v0.30.3.md +75 -0
- package/docs/release-notes/v0.31.0.md +130 -0
- package/docs/schedules.md +4 -1
- package/docs/servers.md +94 -25
- package/docs/souls-and-instances.md +8 -3
- package/lib/attachments.mjs +2 -1
- package/lib/core.mjs +233 -216
- package/lib/errors.mjs +17 -0
- package/lib/instance-inspect.mjs +11 -3
- package/lib/schedule.mjs +21 -8
- package/lib/servers.mjs +265 -45
- package/lib/session-input.mjs +9 -23
- package/lib/session-viewer.mjs +0 -5
- package/lib/triggers.mjs +10 -8
- package/package-catalog.json +1 -1
- package/package.json +1 -1
- package/lib/herdr.mjs +0 -106
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
|
-
|
|
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
|
-
|
|
59
|
-
|
|
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", "
|
|
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", "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
630
|
-
// the saved route that owns it supplies the
|
|
631
|
-
// later never re-routes a viewer); a
|
|
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
|
-
|
|
637
|
-
|
|
638
|
-
|
|
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
|
|
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
|
-
|
|
659
|
-
|
|
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
|
-
|
|
818
|
-
|
|
819
|
-
|
|
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
|
}
|
package/lib/session-input.mjs
CHANGED
|
@@ -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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
}
|
package/lib/session-viewer.mjs
CHANGED
|
@@ -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], {
|