@awebai/oats 0.22.0 → 0.22.2
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/README.md +40 -50
- package/bin/oats.mjs +242 -22
- package/capabilities/oats-authoring/LICENSE +21 -0
- package/capabilities/oats-authoring/oats-package.json +11 -0
- package/capabilities/oats-authoring/oats.json +4 -4
- package/capabilities/oats-authoring/skills/integration-authoring/SKILL.md +63 -0
- package/capabilities/oats-authoring/skills/skill-craft/SKILL.md +109 -0
- package/capabilities/oats-authoring/skills/soul-craft/SKILL.md +109 -0
- package/capabilities/oats-aweb/injects/aweb.md +4 -3
- package/capabilities/oats-aweb/oats.json +7 -7
- package/capabilities/oats-aweb/skills/LICENSE +21 -0
- package/capabilities/oats-aweb/skills/VENDORED.md +26 -0
- package/capabilities/oats-aweb/skills/aweb-identity/SKILL.md +201 -0
- package/capabilities/oats-aweb/skills/aweb-messaging/SKILL.md +161 -0
- package/capabilities/oats-aweb/skills/aweb-messaging/references/messaging-scenarios.md +61 -0
- package/capabilities/oats-aweb/skills/aweb-team-membership/SKILL.md +328 -0
- package/capabilities/oats-aweb/skills/aweb-team-membership/references/team-membership-reference.md +74 -0
- package/capabilities/oats-jira/oats.json +1 -1
- package/capabilities/oats-linear/oats.json +1 -1
- package/capabilities/oats-okf/agents/{memory-harvest.md → memory-harvest/AGENTS.md} +3 -1
- package/capabilities/oats-okf/agents/memory-harvest/soul.yaml +6 -0
- package/capabilities/oats-okf/bin/oats-okf.mjs +201 -54
- package/capabilities/oats-okf/injects/okf.md +7 -0
- package/capabilities/oats-okf/oats.json +5 -2
- package/capabilities/oats-okf/skills/memory-harvest/SKILL.md +42 -1
- package/capabilities/oats-review/oats.json +1 -1
- package/docs/2026-09-03-architecture-proposal.md +642 -0
- package/docs/execution-targets.md +181 -0
- package/docs/first-team-demo.md +87 -0
- package/docs/first-team.md +179 -0
- package/docs/implementation.md +14 -1
- package/docs/integrations.md +83 -65
- package/docs/layers.md +356 -80
- package/docs/migration-from-oas.md +80 -116
- package/docs/oats-config.schema.json +1 -0
- package/docs/operating-team-migration.md +217 -0
- package/docs/release-notes/v0.22.1.md +106 -0
- package/docs/release-notes/v0.22.2.md +69 -0
- package/docs/servers.md +94 -0
- package/docs/souls-and-instances.md +30 -3
- package/lib/core.mjs +626 -415
- package/lib/herdr.mjs +95 -0
- package/lib/servers.mjs +436 -0
- package/lib/session-input.mjs +78 -0
- package/lib/session-viewer.mjs +51 -0
- package/package-catalog.json +2 -2
- package/package.json +1 -1
- package/packages/record/README.md +76 -16
- package/packages/record/bin/capture.mjs +59 -3
- package/packages/record/bin/recall.mjs +67 -1
- package/packages/record/docs/turn-record-sot.md +1 -1
- package/packages/record/lib/sessions-for-home.mjs +130 -0
- package/packages/record/lib/store.mjs +207 -43
- package/skills/oats/SKILL.md +6 -2
- package/capabilities/oats-aweb/package.json +0 -20
- package/capabilities/oats-jira/package.json +0 -25
- package/capabilities/oats-linear/README.md +0 -234
- package/capabilities/oats-linear/package.json +0 -29
- package/capabilities/oats-linear/test/oats-linear.test.mjs +0 -168
- package/capabilities/oats-okf/package.json +0 -22
package/lib/herdr.mjs
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/** Herdr 0.8.x host-local session backend. SSH belongs to the CLI router. */
|
|
2
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
3
|
+
import { existsSync, mkdirSync } from "node:fs";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { dirname, join, resolve } from "node:path";
|
|
6
|
+
|
|
7
|
+
export const HERDR_PROTOCOL = 20;
|
|
8
|
+
const quote = (s) => `'${String(s).replaceAll("'", "'\\''")}'`;
|
|
9
|
+
const pause = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
10
|
+
|
|
11
|
+
export function herdrSocket(session = "oats") {
|
|
12
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(session)) throw new Error("invalid Herdr session name");
|
|
13
|
+
return join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "herdr", "sessions", session, "herdr.sock");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function herdrCommand(target, args, { timeout = 10000, exec = execFileSync } = {}) {
|
|
17
|
+
const env = { ...process.env, HERDR_SOCKET_PATH: target.socket };
|
|
18
|
+
delete env.HERDR_SESSION;
|
|
19
|
+
const output = exec(target.binary || "herdr", args, { env, encoding: "utf8", timeout, maxBuffer: 8 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"] });
|
|
20
|
+
// Some successful terminal mutations have no output; reads below require
|
|
21
|
+
// their documented result shape independently.
|
|
22
|
+
if (!output.trim()) return {};
|
|
23
|
+
const response = JSON.parse(output);
|
|
24
|
+
if (response.error) throw Object.assign(new Error(response.error.message), { code: response.error.code });
|
|
25
|
+
if (!response.result) throw new Error("Herdr returned no result");
|
|
26
|
+
return response.result;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function herdrSnapshot(target, io) {
|
|
30
|
+
const snapshot = herdrCommand(target, ["api", "snapshot"], io).snapshot;
|
|
31
|
+
if (snapshot?.protocol !== HERDR_PROTOCOL || !Array.isArray(snapshot.panes)) {
|
|
32
|
+
throw new Error(`unsupported Herdr protocol ${snapshot?.protocol}; OATS requires ${HERDR_PROTOCOL}`);
|
|
33
|
+
}
|
|
34
|
+
return snapshot;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function ensureHerdr({ binary = "herdr", socket, session = "oats" } = {}) {
|
|
38
|
+
const target = { backend: "herdr", binary, socket: resolve(socket || herdrSocket(session)), protocol: HERDR_PROTOCOL };
|
|
39
|
+
try { herdrSnapshot(target); return target; }
|
|
40
|
+
catch (e) {
|
|
41
|
+
// An explicit socket belongs to an operator-managed server. Never start a
|
|
42
|
+
// different server when it cannot be inspected or is incompatible.
|
|
43
|
+
if (socket || existsSync(target.socket)) throw e;
|
|
44
|
+
}
|
|
45
|
+
mkdirSync(dirname(target.socket), { recursive: true });
|
|
46
|
+
const env = { ...process.env };
|
|
47
|
+
delete env.HERDR_SESSION; delete env.HERDR_SOCKET_PATH;
|
|
48
|
+
const child = spawn(binary, ["--session", session, "server"], { detached: true, stdio: "ignore", env });
|
|
49
|
+
child.on("error", () => {});
|
|
50
|
+
child.unref();
|
|
51
|
+
let error;
|
|
52
|
+
for (let i = 0; i < 30; i++) {
|
|
53
|
+
pause(100);
|
|
54
|
+
try { herdrSnapshot(target); return target; } catch (e) { error = e; }
|
|
55
|
+
}
|
|
56
|
+
throw new Error(`Herdr did not start: ${error?.message || "no socket"}`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function allocateHerdr(target, { home, instance }, io) {
|
|
60
|
+
const result = herdrCommand(target, ["workspace", "create", "--cwd", home, "--label", instance, "--no-focus"], io);
|
|
61
|
+
const pane = result.root_pane;
|
|
62
|
+
if (!pane?.pane_id || !pane?.terminal_id || !pane?.workspace_id) throw new Error("Herdr allocation returned no session identity");
|
|
63
|
+
return { ...target, workspaceId: pane.workspace_id, paneId: pane.pane_id, terminalId: pane.terminal_id };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function inspectHerdr(target, io) {
|
|
67
|
+
const snapshot = herdrSnapshot(target, io);
|
|
68
|
+
const pane = snapshot.panes.find((p) => p.pane_id === target.paneId && p.terminal_id === target.terminalId);
|
|
69
|
+
const agent = pane && snapshot.agents?.find((a) => a.terminal_id === target.terminalId);
|
|
70
|
+
return { present: !!pane, pane, agent, status: agent?.agent_status || "unknown" };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function launchHerdr(target, command, io) {
|
|
74
|
+
if (!inspectHerdr(target, io).present) throw new Error("Herdr launch pane disappeared");
|
|
75
|
+
// This is the allocated shell's one launch command. Exec avoids a fallback
|
|
76
|
+
// shell that could accidentally consume a subsequent agent notification.
|
|
77
|
+
herdrCommand(target, ["pane", "run", target.paneId, `exec /bin/sh -c ${quote(command)}`], io);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function stopHerdr(target, io) {
|
|
81
|
+
if (!inspectHerdr(target, io).present) return;
|
|
82
|
+
try { herdrCommand(target, ["pane", "close", target.paneId], io); } catch { /* inspect the effect even if the pane exited concurrently */ }
|
|
83
|
+
if (inspectHerdr(target, io).present) throw new Error(`Herdr pane ${target.paneId} is still present`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function inputHerdr(target, text, io) {
|
|
87
|
+
if (!inspectHerdr(target, io).present) throw new Error("Herdr session stopped or was replaced");
|
|
88
|
+
herdrCommand(target, ["pane", "run", target.paneId, text], io);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function validHerdrTarget(value) {
|
|
92
|
+
return value?.backend === "herdr" && value.protocol === HERDR_PROTOCOL
|
|
93
|
+
&& [value.socket, value.binary, value.workspaceId, value.paneId, value.terminalId].every((v) => typeof v === "string" && v.length > 0)
|
|
94
|
+
&& value.socket === resolve(value.socket);
|
|
95
|
+
}
|
package/lib/servers.mjs
ADDED
|
@@ -0,0 +1,436 @@
|
|
|
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 { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
21
|
+
import { homedir } from "node:os";
|
|
22
|
+
import { join, resolve } from "node:path";
|
|
23
|
+
|
|
24
|
+
const OATS_HOME_DIR = () => process.env.OATS_HOME_DIR || join(homedir(), ".oats");
|
|
25
|
+
export const SERVERS_FILE = () => join(OATS_HOME_DIR(), "servers.json");
|
|
26
|
+
export const REMOTE_SNAPSHOT_DIR = () => join(OATS_HOME_DIR(), "remote");
|
|
27
|
+
|
|
28
|
+
/** Every ssh invocation is non-interactive: a host that needs a password or a
|
|
29
|
+
* first-time key confirmation fails fast with ssh's own message, instead of
|
|
30
|
+
* a lifecycle call hanging on a prompt nobody will answer. */
|
|
31
|
+
const SSH_OPTS = ["-o", "BatchMode=yes", "-o", "ConnectTimeout=15"];
|
|
32
|
+
|
|
33
|
+
const ID_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
34
|
+
const SSH_HOST_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; // an OpenSSH alias or host name, never a user@ or option
|
|
35
|
+
|
|
36
|
+
export function serverError(code, message) {
|
|
37
|
+
return Object.assign(new Error(message), { code });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------- registry
|
|
41
|
+
|
|
42
|
+
export function readServers() {
|
|
43
|
+
const file = SERVERS_FILE();
|
|
44
|
+
if (!existsSync(file)) return {};
|
|
45
|
+
let doc;
|
|
46
|
+
try {
|
|
47
|
+
doc = JSON.parse(readFileSync(file, "utf8"));
|
|
48
|
+
} catch (e) {
|
|
49
|
+
throw serverError("E_SERVERS_UNREADABLE", `${file} is not valid JSON: ${e.message}`);
|
|
50
|
+
}
|
|
51
|
+
const servers = doc && typeof doc === "object" && !Array.isArray(doc) ? doc.servers : undefined;
|
|
52
|
+
if (!servers || typeof servers !== "object" || Array.isArray(servers)) {
|
|
53
|
+
throw serverError("E_SERVERS_UNREADABLE", `${file} must be { "servers": { <id>: {...} } }`);
|
|
54
|
+
}
|
|
55
|
+
for (const [id, s] of Object.entries(servers)) validateServer(id, s);
|
|
56
|
+
return servers;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function writeServers(servers) {
|
|
60
|
+
const file = SERVERS_FILE();
|
|
61
|
+
mkdirSync(OATS_HOME_DIR(), { recursive: true });
|
|
62
|
+
writeFileSync(file, JSON.stringify({ servers }, null, 2) + "\n", { mode: 0o600 });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** A registration names WHERE and HOW, never WITH WHAT credentials. */
|
|
66
|
+
export function validateServer(id, s) {
|
|
67
|
+
if (!ID_RE.test(String(id))) throw serverError("E_SERVER_INVALID", `server id ${JSON.stringify(id)} must be lowercase letters, digits and dashes`);
|
|
68
|
+
if (!s || typeof s !== "object" || Array.isArray(s)) throw serverError("E_SERVER_INVALID", `server ${id}: entry must be an object`);
|
|
69
|
+
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)`);
|
|
70
|
+
if (typeof s.workspace !== "string" || !s.workspace.startsWith("/")) throw serverError("E_SERVER_INVALID", `server ${id}: workspace must be an absolute path on the server`);
|
|
71
|
+
for (const k of ["oatsPath", "herdrPath", "label", "path"]) {
|
|
72
|
+
if (s[k] !== undefined && s[k] !== null && typeof s[k] !== "string") throw serverError("E_SERVER_INVALID", `server ${id}: ${k} must be a string`);
|
|
73
|
+
}
|
|
74
|
+
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`);
|
|
75
|
+
for (const k of Object.keys(s)) {
|
|
76
|
+
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)`);
|
|
77
|
+
}
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function getServer(id) {
|
|
82
|
+
const servers = readServers();
|
|
83
|
+
const s = servers[id];
|
|
84
|
+
if (!s) throw serverError("E_SERVER_UNKNOWN", `no server registered as ${JSON.stringify(id)} (oats server list)`);
|
|
85
|
+
return { id, ...s };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ------------------------------------------------------------------ quoting
|
|
89
|
+
|
|
90
|
+
/** POSIX single-quoting for the REMOTE shell: ssh joins its command words
|
|
91
|
+
* with spaces and hands the string to the login shell, so every argument
|
|
92
|
+
* must survive that shell untouched. Single quotes are the only construct
|
|
93
|
+
* in which nothing is special; an embedded quote closes, escapes, reopens. */
|
|
94
|
+
export function remoteQuote(arg) {
|
|
95
|
+
const s = String(arg);
|
|
96
|
+
if (s === "") return "''";
|
|
97
|
+
if (/^[A-Za-z0-9._\/=:@%+,-]+$/.test(s)) return s;
|
|
98
|
+
return `'${s.replace(/'/g, `'\\''`)}'`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The exact snapshot of a registration that a route runs against. */
|
|
102
|
+
export function targetOf(server) {
|
|
103
|
+
return {
|
|
104
|
+
sshHost: server.sshHost,
|
|
105
|
+
workspace: server.workspace,
|
|
106
|
+
oatsPath: server.oatsPath || "oats",
|
|
107
|
+
...(server.herdrPath ? { herdrPath: server.herdrPath } : {}),
|
|
108
|
+
...(server.path ? { path: server.path } : {}),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** argv for the local ssh process that runs one remote oats command. `--`
|
|
113
|
+
* ends ssh's own options so a host alias can never be read as one; the
|
|
114
|
+
* remote command is one quoted string, as ssh requires. */
|
|
115
|
+
export function sshArgv(target, oatsArgs) {
|
|
116
|
+
// A non-interactive ssh command runs in the login shell's minimal PATH,
|
|
117
|
+
// which rarely includes user-local tool directories (~/.local/bin, where
|
|
118
|
+
// claude and pi commonly live). A registration may name directories to
|
|
119
|
+
// prepend; `$PATH` stays unquoted so the remote shell expands its own.
|
|
120
|
+
// A leading ~/ means the remote user's home: it becomes an unquoted "$HOME"
|
|
121
|
+
// concatenated with the quoted remainder, so the remote shell expands the
|
|
122
|
+
// one and never touches the other.
|
|
123
|
+
const dirs = target.path ? target.path.split(":").filter(Boolean).map((d) => (d.startsWith("~/") ? `"$HOME"${remoteQuote(d.slice(1))}` : remoteQuote(d))) : [];
|
|
124
|
+
const prefix = dirs.length ? `PATH=${dirs.join(":")}:"$PATH" ` : "";
|
|
125
|
+
const cmd = prefix + [target.oatsPath, ...oatsArgs].map(remoteQuote).join(" ");
|
|
126
|
+
return ["ssh", ...SSH_OPTS, "--", target.sshHost, cmd];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ------------------------------------------------------------------ running
|
|
130
|
+
|
|
131
|
+
/** Parse the remote kernel's JSON envelope; anything else on stdout is a
|
|
132
|
+
* routing failure with the raw text attached, never a guess. */
|
|
133
|
+
export function parseRemoteEnvelope(stdout) {
|
|
134
|
+
const text = String(stdout || "").trim();
|
|
135
|
+
let doc;
|
|
136
|
+
try {
|
|
137
|
+
doc = JSON.parse(text);
|
|
138
|
+
} catch {
|
|
139
|
+
throw serverError("E_REMOTE_ENVELOPE", `the remote oats did not answer with a JSON envelope: ${text.slice(0, 300) || "(empty stdout)"}`);
|
|
140
|
+
}
|
|
141
|
+
if (!doc || typeof doc !== "object") {
|
|
142
|
+
throw serverError("E_REMOTE_ENVELOPE", `the remote oats answered with an unsupported envelope: ${text.slice(0, 300)}`);
|
|
143
|
+
}
|
|
144
|
+
// `status --json` predates the envelope and answers the bare roster
|
|
145
|
+
// { root, agents } (team form: { team, roots }); normalize it too.
|
|
146
|
+
if (doc.schemaVersion === undefined && (Array.isArray(doc.agents) || Array.isArray(doc.roots))) {
|
|
147
|
+
return { schemaVersion: 1, ok: true, result: doc, bare: true };
|
|
148
|
+
}
|
|
149
|
+
// `retire --json` likewise answers its bare result object (oats-1lb).
|
|
150
|
+
if (doc.schemaVersion === undefined && typeof doc.retired === "string") {
|
|
151
|
+
return { schemaVersion: 1, ok: true, result: doc, bare: true };
|
|
152
|
+
}
|
|
153
|
+
if (doc.schemaVersion !== 1) {
|
|
154
|
+
throw serverError("E_REMOTE_ENVELOPE", `the remote oats answered with an unsupported envelope: ${text.slice(0, 300)}`);
|
|
155
|
+
}
|
|
156
|
+
// `version --json` answers the Desktop API v1 probe payload rather than
|
|
157
|
+
// an ok/result envelope; normalize it so every caller sees one shape.
|
|
158
|
+
if (typeof doc.ok !== "boolean") {
|
|
159
|
+
if (doc.desktopApi === 1 && typeof doc.version === "string") return { schemaVersion: 1, ok: true, result: doc, probe: true };
|
|
160
|
+
throw serverError("E_REMOTE_ENVELOPE", `the remote oats answered with an unsupported envelope: ${text.slice(0, 300)}`);
|
|
161
|
+
}
|
|
162
|
+
return doc;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Run one remote oats command and return its envelope. A non-zero exit with
|
|
166
|
+
* a well-formed failure envelope is returned as that envelope (the remote
|
|
167
|
+
* kernel's own error code and message); ssh's own failures (unreachable,
|
|
168
|
+
* refused key, unknown host) surface as E_SSH with ssh's stderr. */
|
|
169
|
+
export function runRemote(target, oatsArgs, io = {}) {
|
|
170
|
+
const exec = io.execFileSync || execFileSync;
|
|
171
|
+
const [bin, ...argv] = sshArgv(target, oatsArgs);
|
|
172
|
+
let stdout = "";
|
|
173
|
+
let stderr = "";
|
|
174
|
+
let status = 0;
|
|
175
|
+
try {
|
|
176
|
+
stdout = exec(bin, argv, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 16 * 1024 * 1024, timeout: io.timeoutMs || 300000 });
|
|
177
|
+
} catch (e) {
|
|
178
|
+
stdout = String(e.stdout || "");
|
|
179
|
+
stderr = String(e.stderr || e.message || "");
|
|
180
|
+
status = typeof e.status === "number" ? e.status : 255;
|
|
181
|
+
}
|
|
182
|
+
if (String(stdout).trim()) {
|
|
183
|
+
const envelope = parseRemoteEnvelope(stdout);
|
|
184
|
+
// A bare `retire --json` answer with cleanup still owed exits 1 on the
|
|
185
|
+
// remote; the normalized envelope must say so too, or a routed retire
|
|
186
|
+
// reports success while the remote home and its external state remain.
|
|
187
|
+
if (envelope.bare && envelope.result?.rollbackIncomplete) {
|
|
188
|
+
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 };
|
|
189
|
+
}
|
|
190
|
+
return { envelope, stderr, status };
|
|
191
|
+
}
|
|
192
|
+
// ssh exits 255 for its own failures; the remote command's exit code is
|
|
193
|
+
// relayed otherwise. Either way with no envelope there is nothing to trust.
|
|
194
|
+
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)"}`);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Version and envelope compatibility, checked BEFORE any mutation. The
|
|
198
|
+
* remote must answer `version --json` with the Desktop API v1 probe payload
|
|
199
|
+
* and a kernel the local side knows how to talk to. */
|
|
200
|
+
export function checkRemote(target, io = {}) {
|
|
201
|
+
const { envelope } = runRemote(target, ["version", "--json"], { ...io, timeoutMs: 60000 });
|
|
202
|
+
const probe = envelope.result || {};
|
|
203
|
+
if (probe.desktopApi !== 1 || typeof probe.version !== "string") {
|
|
204
|
+
throw serverError("E_REMOTE_INCOMPATIBLE", `remote oats at ${target.sshHost} answered an unknown version payload: ${JSON.stringify(probe).slice(0, 200)}`);
|
|
205
|
+
}
|
|
206
|
+
const min = io.minVersion || MIN_REMOTE_VERSION;
|
|
207
|
+
if (compareSemver(probe.version, min) < 0) {
|
|
208
|
+
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`);
|
|
209
|
+
}
|
|
210
|
+
// What the remote kernel advertises it can launch. A probe without these
|
|
211
|
+
// fields is a 0.22.1-class kernel: pi and claude only, no session backend
|
|
212
|
+
// choice, no launch options; nothing newer may be requested of it.
|
|
213
|
+
const list = (k, fallback) => (Array.isArray(probe[k]) ? probe[k].map(String) : fallback);
|
|
214
|
+
return {
|
|
215
|
+
version: probe.version, schemaVersion: 1, desktopApi: 1,
|
|
216
|
+
runtimes: list("runtimes", ["pi", "claude"]),
|
|
217
|
+
sessionBackends: list("sessionBackends", []),
|
|
218
|
+
launchOptions: list("launchOptions", []),
|
|
219
|
+
remote: list("remote", []),
|
|
220
|
+
advertised: Array.isArray(probe.runtimes),
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Refuse, before any mutation, a spawn that asks the remote kernel for a
|
|
225
|
+
* runtime, session backend or launch option it does not advertise. The
|
|
226
|
+
* effective runtime is the --runtime flag or the soul's default from the
|
|
227
|
+
* remote roster, resolved THERE, since the local roster says nothing about
|
|
228
|
+
* that host's souls. Desktop's local support check cannot prove remote
|
|
229
|
+
* support; this is the proof. */
|
|
230
|
+
export function checkRemoteSupport(remote, target, oatsArgs, roster) {
|
|
231
|
+
const flagOf = (name) => { const i = oatsArgs.indexOf(name); return i >= 0 && oatsArgs[i + 1] && !oatsArgs[i + 1].startsWith("--") ? oatsArgs[i + 1] : undefined; };
|
|
232
|
+
const agent = oatsArgs.find((a) => !a.startsWith("--"));
|
|
233
|
+
const soul = (roster?.agents || []).find((a) => a.name === agent);
|
|
234
|
+
// The effective runtime is the flag, else the soul's default as the remote
|
|
235
|
+
// roster reports it. A soul the roster does not list with a runtime (a
|
|
236
|
+
// capability-defined agent, or one with no live instance) has no default
|
|
237
|
+
// this side can establish: the remote kernel validates its own default at
|
|
238
|
+
// spawn, and nothing is asserted here about it.
|
|
239
|
+
const runtime = flagOf("--runtime") || soul?.runtime;
|
|
240
|
+
// What the message may claim depends on what was established: an
|
|
241
|
+
// advertising remote said what it supports; a silent one (before 0.22.2)
|
|
242
|
+
// said nothing, and only pi and claude are assumed of it.
|
|
243
|
+
const supports = remote.advertised
|
|
244
|
+
? `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"}`
|
|
245
|
+
: `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`;
|
|
246
|
+
const refuse = (what) => { throw serverError("E_REMOTE_INCOMPATIBLE", `remote oats ${remote.version} at ${target.sshHost}: ${what} was not established as supported there (${supports})`); };
|
|
247
|
+
if (runtime && !remote.runtimes.includes(runtime)) refuse(`runtime ${runtime}${flagOf("--runtime") ? "" : ` (the default of soul ${agent} there)`}`);
|
|
248
|
+
const backend = flagOf("--backend");
|
|
249
|
+
if (backend && !remote.sessionBackends.includes(backend)) refuse(`session backend ${backend}`);
|
|
250
|
+
if (oatsArgs.includes("--yolo") && !remote.launchOptions.includes("yolo")) refuse("the yolo launch option");
|
|
251
|
+
return { runtime, backend, yolo: oatsArgs.includes("--yolo") };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** The remote kernel version that carries `oats session` (inspect, input,
|
|
255
|
+
* attach): routed session commands need at least this. */
|
|
256
|
+
export const SESSION_REMOTE_VERSION = "0.22.2";
|
|
257
|
+
|
|
258
|
+
/** The oldest remote kernel this router talks to: 0.22.1 answers the v1
|
|
259
|
+
* probe and roster and runs the lifecycle over ssh (qualified live on
|
|
260
|
+
* aweb-agents), but knows nothing of session backends, launch options,
|
|
261
|
+
* deferred self-retirement or the `oats session` commands (all 0.22.2);
|
|
262
|
+
* checkRemoteSupport keeps spawn requests within what a given remote
|
|
263
|
+
* advertises and session routes require SESSION_REMOTE_VERSION. The floor
|
|
264
|
+
* moves to 0.22.2 with that release. */
|
|
265
|
+
export const MIN_REMOTE_VERSION = "0.22.1";
|
|
266
|
+
|
|
267
|
+
export function compareSemver(a, b) {
|
|
268
|
+
const pa = String(a).split(/[.-]/).map((x) => Number.parseInt(x, 10));
|
|
269
|
+
const pb = String(b).split(/[.-]/).map((x) => Number.parseInt(x, 10));
|
|
270
|
+
for (let i = 0; i < 3; i++) {
|
|
271
|
+
const d = (pa[i] || 0) - (pb[i] || 0);
|
|
272
|
+
if (d) return d;
|
|
273
|
+
}
|
|
274
|
+
return 0;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ---------------------------------------------------------------- snapshots
|
|
278
|
+
|
|
279
|
+
export function snapshotPath(serverId, instance) {
|
|
280
|
+
if (!ID_RE.test(String(serverId))) throw serverError("E_SERVER_INVALID", `bad server id ${JSON.stringify(serverId)}`);
|
|
281
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(String(instance))) throw serverError("E_BAD_ARGS", `bad instance name ${JSON.stringify(instance)}`);
|
|
282
|
+
return join(REMOTE_SNAPSHOT_DIR(), serverId, `${instance}.json`);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** The local representation of a remote instance: the route it was spawned
|
|
286
|
+
* through, frozen. `serverId` is for display; every later operation uses
|
|
287
|
+
* `target`. Remote state is never copied here — status is pulled. */
|
|
288
|
+
export function writeSnapshot(serverId, target, remote, spawnResult) {
|
|
289
|
+
const snap = {
|
|
290
|
+
serverId,
|
|
291
|
+
target,
|
|
292
|
+
remote: { version: remote.version, schemaVersion: remote.schemaVersion },
|
|
293
|
+
instance: spawnResult.instance,
|
|
294
|
+
agent: spawnResult.agent,
|
|
295
|
+
home: spawnResult.home,
|
|
296
|
+
agentsRoot: spawnResult.agentsRoot,
|
|
297
|
+
spawnedAt: new Date().toISOString(),
|
|
298
|
+
};
|
|
299
|
+
const p = snapshotPath(serverId, spawnResult.instance);
|
|
300
|
+
mkdirSync(join(REMOTE_SNAPSHOT_DIR(), serverId), { recursive: true });
|
|
301
|
+
writeFileSync(p, JSON.stringify(snap, null, 2) + "\n");
|
|
302
|
+
return snap;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export function readSnapshot(serverId, instance) {
|
|
306
|
+
const p = snapshotPath(serverId, instance);
|
|
307
|
+
if (!existsSync(p)) return undefined;
|
|
308
|
+
try {
|
|
309
|
+
return JSON.parse(readFileSync(p, "utf8"));
|
|
310
|
+
} catch (e) {
|
|
311
|
+
throw serverError("E_SNAPSHOT_UNREADABLE", `${p}: ${e.message}`);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export function removeSnapshot(serverId, instance) {
|
|
316
|
+
rmSync(snapshotPath(serverId, instance), { force: true });
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export function listSnapshots(serverId) {
|
|
320
|
+
const dir = join(REMOTE_SNAPSHOT_DIR(), serverId);
|
|
321
|
+
if (!existsSync(dir)) return [];
|
|
322
|
+
const out = [];
|
|
323
|
+
for (const f of readdirSync(dir)) {
|
|
324
|
+
if (!f.endsWith(".json")) continue;
|
|
325
|
+
try {
|
|
326
|
+
out.push(JSON.parse(readFileSync(join(dir, f), "utf8")));
|
|
327
|
+
} catch {
|
|
328
|
+
out.push({ serverId, instance: f.replace(/\.json$/, ""), unreadable: true });
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
return out;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ------------------------------------------------------------------- routes
|
|
335
|
+
|
|
336
|
+
/** Route one lifecycle command to a server. Returns the remote envelope,
|
|
337
|
+
* having kept the local snapshot store in step with it:
|
|
338
|
+
* - spawn: compatibility checked first, a snapshot written on success;
|
|
339
|
+
* - retire: the target comes from the instance's snapshot when one exists
|
|
340
|
+
* (the registry may have changed since), the snapshot is removed only
|
|
341
|
+
* when the remote kernel reports the home gone;
|
|
342
|
+
* - status: pulled from the remote kernel, never cached. */
|
|
343
|
+
export function routeCommand(serverId, cmd, oatsArgs, io = {}) {
|
|
344
|
+
// A retirement may outlive its registration: the snapshot taken at spawn
|
|
345
|
+
// is the route, and it is consulted before the registry is required.
|
|
346
|
+
const instanceArg = oatsArgs.find((a) => !a.startsWith("--"));
|
|
347
|
+
const snap = cmd === "retire" && instanceArg ? readSnapshot(serverId, instanceArg) : undefined;
|
|
348
|
+
let server = io.server;
|
|
349
|
+
if (!server) {
|
|
350
|
+
try { server = getServer(serverId); }
|
|
351
|
+
catch (e) { if (!snap?.target) throw e; }
|
|
352
|
+
}
|
|
353
|
+
let target = snap?.target || targetOf(server);
|
|
354
|
+
const withScope = (args) => (args.includes("--dir") ? args : [...args, "--dir", target.workspace]);
|
|
355
|
+
const json = (args) => (args.includes("--json") ? args : [...args, "--json"]);
|
|
356
|
+
|
|
357
|
+
if (cmd === "spawn") {
|
|
358
|
+
const remote = checkRemote(target, io);
|
|
359
|
+
// The remote roster: the soul's runtime default for the support check,
|
|
360
|
+
// and the remote agents root for the snapshot (the kernel's spawn result
|
|
361
|
+
// does not carry it, and guessing it from the workspace would be wrong
|
|
362
|
+
// for local souls).
|
|
363
|
+
const status = runRemote(target, json(withScope(["status"])), io).envelope;
|
|
364
|
+
if (!status.ok) return { envelope: status, stderr: "" };
|
|
365
|
+
checkRemoteSupport(remote, target, oatsArgs, status.result);
|
|
366
|
+
const { envelope, stderr } = runRemote(target, json(withScope(["spawn", ...oatsArgs])), io);
|
|
367
|
+
if (envelope.ok && envelope.result?.instance) {
|
|
368
|
+
const snapshot = writeSnapshot(serverId, target, remote, { ...envelope.result, agentsRoot: envelope.result.agentsRoot || status.result.root });
|
|
369
|
+
return { envelope: { ...envelope, result: { ...envelope.result, server: serverId, target, snapshot: snapshotPath(serverId, envelope.result.instance) } }, stderr, snapshot };
|
|
370
|
+
}
|
|
371
|
+
return { envelope, stderr };
|
|
372
|
+
}
|
|
373
|
+
if (cmd === "retire") {
|
|
374
|
+
const name = instanceArg;
|
|
375
|
+
checkRemote(target, io); // a mutation: version and envelope first, like spawn
|
|
376
|
+
const { envelope, stderr } = runRemote(target, json(withScope(["retire", ...oatsArgs])), io);
|
|
377
|
+
// The snapshot goes only when the remote home is gone: not on incomplete
|
|
378
|
+
// cleanup, and not on a deferred completion still on its way there.
|
|
379
|
+
if (envelope.ok && name && envelope.result?.removedDir !== false && !envelope.result?.rollbackIncomplete && !envelope.result?.deferred) removeSnapshot(serverId, name);
|
|
380
|
+
// The routing context rides on the result whenever there is one, ok or
|
|
381
|
+
// not: an incomplete cleanup is exactly when the operator needs the host.
|
|
382
|
+
return { envelope: envelope.result ? { ...envelope, result: { ...envelope.result, server: serverId, target } } : envelope, stderr };
|
|
383
|
+
}
|
|
384
|
+
if (cmd === "status") {
|
|
385
|
+
const { envelope, stderr } = runRemote(target, json(withScope(["status", ...oatsArgs])), io);
|
|
386
|
+
return { envelope: envelope.ok ? { ...envelope, result: { ...envelope.result, server: serverId, target, snapshots: listSnapshots(serverId) } } : envelope, stderr };
|
|
387
|
+
}
|
|
388
|
+
throw serverError("E_USAGE", `--server routes spawn, retire, status and session attach only (not ${cmd})`);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** argv for an INTERACTIVE remote command (a viewer attach): ssh with a PTY,
|
|
392
|
+
* stdio inherited by the caller. The home is resolved from the instance's
|
|
393
|
+
* snapshot when only an instance name is given, so the route is the saved
|
|
394
|
+
* one and nothing about the remote binary or path comes from the caller. */
|
|
395
|
+
/** The saved route for a remote instance: the snapshot's target and home
|
|
396
|
+
* when an instance name is given (spawned from here), else the registry's
|
|
397
|
+
* target with a caller-supplied absolute remote home. Nothing about the
|
|
398
|
+
* remote binary or path ever comes from the caller. */
|
|
399
|
+
export function resolveRoute(serverId, { instance, home } = {}, what = "session") {
|
|
400
|
+
const snap = instance ? readSnapshot(serverId, instance) : undefined;
|
|
401
|
+
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})`);
|
|
402
|
+
const target = snap?.target || targetOf(getServer(serverId));
|
|
403
|
+
const remoteHome = home || snap?.home;
|
|
404
|
+
if (!remoteHome || !String(remoteHome).startsWith("/")) throw serverError("E_BAD_ARGS", `${what} --server needs --instance <name> (spawned from here) or --home </absolute/remote/home>`);
|
|
405
|
+
return { target, home: remoteHome, snapshot: snap };
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** `session inspect` on the execution host for a remote instance: the same
|
|
409
|
+
* route resolution as attach, the standard envelope relayed as is (ok or
|
|
410
|
+
* not), never an ACK of anything. */
|
|
411
|
+
/** A version is a proxy; the probe's `remote` list states the capability
|
|
412
|
+
* directly: a kernel that routes session itself also serves it. A silent
|
|
413
|
+
* probe (before 0.22.2) or one without session in the list is refused
|
|
414
|
+
* before any session command is sent. */
|
|
415
|
+
function requireSessionRemote(target, io) {
|
|
416
|
+
const remote = checkRemote(target, io);
|
|
417
|
+
if (!remote.remote.includes("session")) {
|
|
418
|
+
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`);
|
|
419
|
+
}
|
|
420
|
+
return remote;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
export function inspectRemote(serverId, { instance, home } = {}, io = {}) {
|
|
424
|
+
const route = resolveRoute(serverId, { instance, home }, "session inspect");
|
|
425
|
+
requireSessionRemote(route.target, io);
|
|
426
|
+
const { envelope, stderr } = runRemote(route.target, ["session", "inspect", "--home", route.home, "--json"], io);
|
|
427
|
+
return { envelope: envelope.ok ? { ...envelope, result: { ...envelope.result, server: serverId, instance: instance || route.snapshot?.instance, home: route.home } } : envelope, stderr, route };
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
export function attachArgv(serverId, { instance, home } = {}, io = {}) {
|
|
431
|
+
const { target, home: remoteHome } = resolveRoute(serverId, { instance, home }, "session attach");
|
|
432
|
+
if (!io.skipVersionCheck) requireSessionRemote(target, io);
|
|
433
|
+
const [bin, ...rest] = sshArgv(target, ["session", "attach", "--home", remoteHome]);
|
|
434
|
+
// -t: allocate a PTY; placed before `--` with the other ssh options.
|
|
435
|
+
return { argv: [bin, "-t", ...rest], target, home: remoteHome };
|
|
436
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/** Host-local terminal transport. Delivery policy belongs to the caller. */
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { basename } from "node:path";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { inspectHerdr, inputHerdr, herdrCommand } from "./herdr.mjs";
|
|
6
|
+
|
|
7
|
+
const shells = new Set(["sh", "bash", "zsh", "fish", "dash", "ksh", "login"]);
|
|
8
|
+
function tmux(target, args, { exec = execFileSync, input } = {}) {
|
|
9
|
+
return exec("tmux", ["-S", target.socket, ...args], {
|
|
10
|
+
encoding: "utf8", input, timeout: 10000, maxBuffer: 1024 * 1024,
|
|
11
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
export function inspectSessionTarget(target, io) {
|
|
15
|
+
if (target.backend === "herdr") {
|
|
16
|
+
const s = inspectHerdr(target, io);
|
|
17
|
+
let state = s.present ? s.status : "stopped";
|
|
18
|
+
if (s.present && !s.agent) {
|
|
19
|
+
const processes = herdrCommand(target, ["pane", "process-info", "--pane", target.paneId], io).process_info?.foreground_processes;
|
|
20
|
+
if (!Array.isArray(processes)) throw new Error("Herdr returned no process information");
|
|
21
|
+
if (!processes.length || processes.every((p) => shells.has(p.name))) state = "shell";
|
|
22
|
+
}
|
|
23
|
+
return { backend: "herdr", present: s.present, state, terminalId: target.terminalId };
|
|
24
|
+
}
|
|
25
|
+
let rows;
|
|
26
|
+
try {
|
|
27
|
+
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);
|
|
28
|
+
} catch (e) {
|
|
29
|
+
// A missing window on a reachable server is absence. A lost socket is not.
|
|
30
|
+
if (/can't find (window|session)/i.test(String(e.stderr || ""))) return { backend: "tmux", present: false, state: "stopped" };
|
|
31
|
+
throw e;
|
|
32
|
+
}
|
|
33
|
+
if (!rows.length) return { backend: "tmux", present: false, state: "stopped" };
|
|
34
|
+
if (rows.length !== 1) throw new Error("session window has multiple panes; choose an unsplit agent window");
|
|
35
|
+
const [paneId, dead, command, panePid] = rows[0].split("\t");
|
|
36
|
+
if (!/^%\d+$/.test(paneId) || !["0", "1"].includes(dead)) throw new Error("invalid tmux pane response");
|
|
37
|
+
let state = dead === "1" ? "stopped" : "unknown";
|
|
38
|
+
if (dead === "0" && shells.has(command)) {
|
|
39
|
+
// macOS tmux can report the wrapper shell while the harness is its child.
|
|
40
|
+
// Only a shell with no non-shell descendants is a fallback prompt.
|
|
41
|
+
if (!/^\d+$/.test(panePid)) throw new Error("tmux returned no pane process id");
|
|
42
|
+
const output = (io?.exec || execFileSync)("ps", ["-axo", "pid=,ppid=,comm="], {
|
|
43
|
+
encoding: "utf8", timeout: 10000, maxBuffer: 4 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"],
|
|
44
|
+
});
|
|
45
|
+
const processes = output.split("\n").map((line) => line.trim().match(/^(\d+)\s+(\d+)\s+(.+)$/)).filter(Boolean);
|
|
46
|
+
const descendants = new Set([panePid]);
|
|
47
|
+
let active = false;
|
|
48
|
+
for (let changed = true; changed;) {
|
|
49
|
+
changed = false;
|
|
50
|
+
for (const [, pid, parent, name] of processes) {
|
|
51
|
+
if (descendants.has(parent) && !descendants.has(pid)) {
|
|
52
|
+
descendants.add(pid); changed = true;
|
|
53
|
+
if (!shells.has(basename(name).replace(/^-/, ""))) active = true;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
state = active ? "unknown" : "shell";
|
|
58
|
+
}
|
|
59
|
+
return { backend: "tmux", present: dead === "0", state, paneId };
|
|
60
|
+
}
|
|
61
|
+
export function inputSessionTarget(target, text, io) {
|
|
62
|
+
const state = inspectSessionTarget(target, io);
|
|
63
|
+
if (!state.present || state.state === "shell") throw new Error(`cannot submit input: session is ${state.state}`);
|
|
64
|
+
if (target.backend === "herdr") inputHerdr(target, text, io);
|
|
65
|
+
else {
|
|
66
|
+
// Bracketed paste preserves multiline input as one user message. No text
|
|
67
|
+
// is evaluated by a shell or interpreted as tmux key names.
|
|
68
|
+
const buffer = `oats-${randomUUID()}`;
|
|
69
|
+
try {
|
|
70
|
+
tmux(target, ["load-buffer", "-b", buffer, "-"], { ...io, input: text });
|
|
71
|
+
tmux(target, ["paste-buffer", "-p", "-b", buffer, "-t", state.paneId], io);
|
|
72
|
+
tmux(target, ["send-keys", "-t", state.paneId, "Enter"], io);
|
|
73
|
+
} finally {
|
|
74
|
+
try { tmux(target, ["delete-buffer", "-b", buffer], io); } catch { /* already consumed or disconnected */ }
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return { ...state, submitted: true };
|
|
78
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/** Interactive host-local viewers. Closing a viewer leaves its agent alive. */
|
|
2
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { inspectSessionTarget } from "./session-input.mjs";
|
|
5
|
+
|
|
6
|
+
export function prepareSessionViewer(target, { exec = execFileSync } = {}) {
|
|
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
|
+
const run = (args) => exec("tmux", ["-S", target.socket, ...args], {
|
|
14
|
+
encoding: "utf8", timeout: 10000, stdio: ["ignore", "pipe", "pipe"],
|
|
15
|
+
}).trim();
|
|
16
|
+
const viewer = `oatsview-${process.pid}-${randomUUID().slice(0, 8)}`;
|
|
17
|
+
const cleanup = () => { try { run(["kill-session", "-t", `=${viewer}`]); } catch { /* already detached/ended */ } };
|
|
18
|
+
try {
|
|
19
|
+
const placeholder = run(["new-session", "-d", "-s", viewer, "-P", "-F", "#{window_id}"]);
|
|
20
|
+
if (!/^@\d+$/.test(placeholder)) throw new Error("tmux returned no viewer window id");
|
|
21
|
+
run(["link-window", "-s", `=${target.session}:=${target.window}`, "-t", `=${viewer}:`]);
|
|
22
|
+
run(["kill-window", "-t", placeholder]);
|
|
23
|
+
// One linked window prevents a stale viewer from selecting a sibling agent
|
|
24
|
+
// when this agent retires. Disable window navigation in the viewer only.
|
|
25
|
+
for (const name of ["prefix", "prefix2"]) run(["set-option", "-t", viewer, name, "None"]);
|
|
26
|
+
run(["set-option", "-t", viewer, "key-table", "oatsview-locked"]);
|
|
27
|
+
run(["unbind-key", "-a", "-q", "-T", "oatsview-locked"]);
|
|
28
|
+
run(["bind-key", "-T", "oatsview-locked", "WheelUpPane", "if-shell", "-F", "#{||:#{pane_in_mode},#{mouse_any_flag}}", "send-keys -M", "copy-mode -e; send-keys -M"]);
|
|
29
|
+
run(["set-option", "-t", viewer, "mouse", "on"]);
|
|
30
|
+
const env = { ...process.env }; delete env.TMUX;
|
|
31
|
+
return { binary: "tmux", args: ["-S", target.socket, "attach-session", "-t", `=${viewer}`], env, cleanup };
|
|
32
|
+
} catch (e) { cleanup(); throw e; }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function attachSessionTarget(target) {
|
|
36
|
+
const viewer = prepareSessionViewer(target);
|
|
37
|
+
let child;
|
|
38
|
+
const stop = () => { viewer.cleanup(); child?.kill(); };
|
|
39
|
+
const signals = ["SIGHUP", "SIGTERM", "SIGINT"];
|
|
40
|
+
for (const sig of signals) process.on(sig, stop);
|
|
41
|
+
try {
|
|
42
|
+
return await new Promise((resolve, reject) => {
|
|
43
|
+
child = spawn(viewer.binary, viewer.args, { env: viewer.env, stdio: "inherit" });
|
|
44
|
+
child.once("error", reject);
|
|
45
|
+
child.once("exit", (code) => resolve(code ?? 1));
|
|
46
|
+
});
|
|
47
|
+
} finally {
|
|
48
|
+
for (const sig of signals) process.removeListener(sig, stop);
|
|
49
|
+
viewer.cleanup();
|
|
50
|
+
}
|
|
51
|
+
}
|