@awebai/oats 0.38.2 → 0.39.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/remote.mjs CHANGED
@@ -441,11 +441,24 @@ function classifyText(error) {
441
441
  return null;
442
442
  }
443
443
 
444
+ /** What to do when git on a macOS host cannot reach its keychain: the credentials git uses must live
445
+ * somewhere a session without a terminal can read. */
446
+ export const KEYCHAIN_REMEDY = "on macOS a session without a terminal (an ssh command, a background job) cannot open the login keychain where git's credential helper keeps the forge token; store git credentials outside the keychain (`gh auth login --insecure-storage`, then `gh auth setup-git`), or use an SSH key the session can reach: one without a passphrase, or the desktop login's ssh-agent (point SSH_AUTH_SOCK at it in the shell's startup file)";
447
+
448
+ /** "keychain-non-interactive" when an `auth` failure happened on macOS in a non-interactive session
449
+ * (no terminal on stdin, or an ssh session), else null. A fact about where git ran, never a probe of
450
+ * the keychain or of any credential. */
451
+ export function keychainHint({ platform = process.platform, stdinIsTTY = process.stdin.isTTY, env = process.env, reason } = {}) {
452
+ if (platform !== "darwin" || reason !== "auth") return null;
453
+ return !stdinIsTTY || !!env.SSH_CONNECTION ? "keychain-non-interactive" : null;
454
+ }
455
+
444
456
  function unreadable(ref, error, extra = {}) {
445
457
  const reason = classifyRemoteFailure(error), killed = reason === "killed";
446
458
  if (reason === "cache") return cacheFailure(ref, error, extra);
447
- return fail("E_REMOTE_UNREADABLE", `cannot read remote ${ref.url} (${reason})${killed ? `: git was killed (signal ${error.signal})` : ""}`,
448
- { url: ref.url, key: ref.key, reason, ...(killed ? { signal: error.signal } : {}), ...extra });
459
+ const hint = keychainHint({ reason });
460
+ return fail("E_REMOTE_UNREADABLE", `cannot read remote ${ref.url} (${reason})${killed ? `: git was killed (signal ${error.signal})` : ""}${hint ? `: ${KEYCHAIN_REMEDY}` : ""}`,
461
+ { url: ref.url, key: ref.key, reason, ...(killed ? { signal: error.signal } : {}), ...(hint ? { hint, remedy: KEYCHAIN_REMEDY } : {}), ...extra });
449
462
  }
450
463
 
451
464
  /** The lock file a git lock error names (`Unable to create '<path>.lock'`, `could not lock config file <path>`),
@@ -2126,7 +2139,8 @@ function createDigest() {
2126
2139
  }
2127
2140
 
2128
2141
  /** Same digest as fetchRemoteTree, computed over a local directory. Symlinks and
2129
- * non-regular entries are refused (E_REMOTE_TREE_UNSAFE); a missing dir → E_REMOTE_PATH_MISSING. */
2142
+ * non-regular entries are refused (E_REMOTE_TREE_UNSAFE); a missing dir → E_REMOTE_PATH_MISSING.
2143
+ * `omit(relPath, absPath)` → true leaves an entry out (what a copy gained after its fetch). */
2130
2144
  function posixNormalize(p) {
2131
2145
  const out = [];
2132
2146
  for (const seg of p.split("/")) { if (!seg || seg === ".") continue; if (seg === "..") { if (out.length && out.at(-1) !== "..") out.pop(); else out.push(".."); } else out.push(seg); }
@@ -2142,7 +2156,7 @@ function posixNormalize(p) {
2142
2156
  */
2143
2157
  export const OATS_ALIAS_SYMLINK = (relPath) => typeof relPath === "string" && (relPath === "CLAUDE.md" || relPath.endsWith("/CLAUDE.md"));
2144
2158
 
2145
- export function contentDigest(dir, { allowSymlinks = null } = {}) {
2159
+ export function contentDigest(dir, { allowSymlinks = null, omit = null } = {}) {
2146
2160
  if (typeof dir !== "string" || !isAbsolute(dir)) throw fail("E_REPO_REF", "contentDigest requires an absolute directory path", { path: dir });
2147
2161
  let st;
2148
2162
  try { st = lstatSync(dir); } catch { throw fail("E_REMOTE_PATH_MISSING", `${dir} does not exist`, { path: dir }); }
@@ -2152,6 +2166,7 @@ export function contentDigest(dir, { allowSymlinks = null } = {}) {
2152
2166
  for (const name of readdirSync(abs)) {
2153
2167
  if (!rel && name === ".git") continue; // a local checkout's metadata is not content
2154
2168
  const p = join(abs, name), r = rel ? `${rel}/${name}` : name, s = lstatSync(p);
2169
+ if (typeof omit === "function" && omit(r, p)) continue;
2155
2170
  if (s.isSymbolicLink()) {
2156
2171
  // Refused by default (contract §1). The same narrow opt-in fetchRemoteTree
2157
2172
  // honours (a relative, non-escaping alias such as CLAUDE.md → AGENTS.md)
package/lib/schedule.mjs CHANGED
@@ -30,6 +30,7 @@ import { Cron } from "croner";
30
30
  import { loadLocal } from "./workspace.mjs";
31
31
  import { noteRuntimeName } from "./deprecation.mjs";
32
32
  import { herdrSettingRemoved } from "./errors.mjs";
33
+ import { withDirLock as withSharedDirLock } from "./dir-lock.mjs";
33
34
  import { tickTriggers } from "./triggers.mjs";
34
35
  import { resolveMemberClone } from "./instance-resolution.mjs";
35
36
  import { AUTOMATION_ID_RE, MODEL_RE, automationContext, automationError, baseRow, localEntry, localId, soulOriginOf, splitId } from "./automations.mjs";
@@ -224,33 +225,9 @@ export function writeHostState(st) { writeJson(join(hostScheduleDir(), "state.js
224
225
 
225
226
  // ---------------------------------------------------------------- locks
226
227
 
227
- function pidAlive(pid) { try { process.kill(pid, 0); return true; } catch (e) { return e.code === "EPERM"; } }
228
- const pause = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
229
-
230
- /** A mkdir lock that is never reclaimed by another process: an existing
231
- * lock whose owner is unreadable or dead is refused with the directory to
232
- * remove, because the gap between mkdir and owner.json belongs to a live
233
- * acquirer and a dead-owner reclaim races every other acquirer. The
234
- * holder removes its own lock in finally and on SIGINT/SIGTERM. */
228
+ /** Schedule locks (lib/dir-lock.mjs): a holder still there after the retry is E_SCHEDULER_BUSY. */
235
229
  function withDirLock(dir, what, fn, { retryMs = 0 } = {}) {
236
- mkdirSync(dirname(dir), { recursive: true });
237
- const deadline = Date.now() + retryMs;
238
- for (;;) {
239
- try { mkdirSync(dir); break; }
240
- catch (e) {
241
- if (e.code !== "EEXIST") throw e;
242
- let owner; try { owner = JSON.parse(readFileSync(join(dir, "owner.json"), "utf8")); } catch { owner = undefined; }
243
- if (owner?.pid && pidAlive(owner.pid) && Date.now() < deadline) { pause(50); continue; }
244
- const why = !owner ? "its owner is not readable yet or the file is missing" : pidAlive(owner.pid) ? `pid ${owner.pid} holds it` : `its owner pid ${owner.pid} is gone`;
245
- throw scheduleError("E_SCHEDULER_BUSY", `${what} is locked (${why}). If no oats schedule process is running, remove ${dir} and retry; nothing was changed.`);
246
- }
247
- }
248
- writeFileSync(join(dir, "owner.json"), JSON.stringify({ pid: process.pid, at: new Date().toISOString(), what }) + "\n");
249
- const release = () => { try { rmSync(dir, { recursive: true, force: true }); } catch { /* best effort */ } };
250
- const onSignal = (sig) => { release(); process.exit(sig === "SIGINT" ? 130 : 143); };
251
- process.once("SIGINT", onSignal); process.once("SIGTERM", onSignal);
252
- try { return fn(); }
253
- finally { process.off("SIGINT", onSignal); process.off("SIGTERM", onSignal); release(); }
230
+ return withSharedDirLock(dir, what, fn, { retryMs, busy: (why) => scheduleError("E_SCHEDULER_BUSY", `${what} is locked (${why}). If no oats schedule process is running, remove ${dir} and retry; nothing was changed.`) });
254
231
  }
255
232
  export function withHostLock(fn, { retryMs = 0 } = {}) { return withDirLock(join(hostScheduleDir(), "host.lock"), "the host scheduler", fn, { retryMs }); }
256
233
  /** Registry read-modify-write is serialized on its own short lock. */
package/lib/servers.mjs CHANGED
@@ -16,13 +16,14 @@
16
16
  * nothing here runs Git against a remote path.
17
17
  */
18
18
 
19
- import { execFileSync } from "node:child_process";
19
+ import { execFileSync, spawnSync } from "node:child_process";
20
20
  import { createHash } from "node:crypto";
21
21
  import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
22
22
  import { homedir } from "node:os";
23
23
  import { basename, join, resolve } from "node:path";
24
24
  import { parseStrictJson } from "./canonical-json.mjs";
25
25
  import { noteRuntimeName } from "./deprecation.mjs";
26
+ import { withDirLock } from "./dir-lock.mjs";
26
27
 
27
28
  const OATS_HOME_DIR = () => process.env.OATS_HOME_DIR || join(homedir(), ".oats");
28
29
  export const SERVERS_FILE = () => join(OATS_HOME_DIR(), "servers.json");
@@ -130,6 +131,35 @@ export function writeServers(servers) {
130
131
  writeFileSync(file, JSON.stringify({ servers }, null, 2) + "\n", { mode: 0o600 });
131
132
  }
132
133
 
134
+ /** Every read-modify-write of the registry: under one short lock, over the file as it is NOW (never a
135
+ * copy read before a network call), written only when `change` altered it. `change(servers)`
136
+ * mutates its argument and returns what the caller needs. */
137
+ export function updateServers(change) {
138
+ const lock = join(OATS_HOME_DIR(), "servers.lock");
139
+ return withDirLock(lock, "the server registry", () => {
140
+ const servers = readServers();
141
+ const before = JSON.stringify(servers);
142
+ const out = change(servers);
143
+ if (JSON.stringify(servers) !== before) writeServers(servers);
144
+ return out;
145
+ }, { retryMs: 5000, busy: (why) => serverError("E_SERVERS_BUSY", `the server registry is locked (${why}); if no oats process is changing it, remove ${lock} and retry; nothing was changed`) });
146
+ }
147
+
148
+ /** Record the workspace key a host reported for registration `id`, only if the registration is still
149
+ * the one that was asked (same host, workspace, oats path and PATH), so a registration changed or
150
+ * removed meanwhile is never overwritten or brought back. → "recorded" | "same" | "changed" |
151
+ * { mismatch: <the recorded key> } (a recorded key is never rewritten). */
152
+ export function learnWorkspaceKey(id, asked, reported) {
153
+ return updateServers((servers) => {
154
+ const current = servers[id];
155
+ if (!current || JSON.stringify(targetOf(current)) !== JSON.stringify(targetOf(asked))) return "changed";
156
+ if (current.workspaceKey === reported) return "same";
157
+ if (current.workspaceKey) return { mismatch: current.workspaceKey };
158
+ current.workspaceKey = reported;
159
+ return "recorded";
160
+ });
161
+ }
162
+
133
163
  /** A registration or saved route written before 0.31.0 may record `herdrPath` (Herdr was removed
134
164
  * then): it is read and dropped, never written, printed or passed. */
135
165
  function withoutHerdrPath(entry) {
@@ -148,11 +178,33 @@ export function validateServer(id, s) {
148
178
  if (s[k] !== undefined && s[k] !== null && typeof s[k] !== "string") throw serverError("E_SERVER_INVALID", `server ${id}: ${k} must be a string`);
149
179
  }
150
180
  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`);
181
+ // The canonical key of the workspace the host deployment realizes, as its own `status --json` reported it.
182
+ if (s.workspaceKey !== undefined && (typeof s.workspaceKey !== "string" || !WORKSPACE_KEY_RE.test(s.workspaceKey))) throw serverError("E_SERVER_INVALID", `server ${id}: workspaceKey must be a canonical workspace key (as the host's oats status --json reports it)`);
151
183
  for (const k of Object.keys(s)) {
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)`);
184
+ if (!["label", "sshHost", "workspace", "oatsPath", "path", "workspaceKey"].includes(k)) throw serverError("E_SERVER_INVALID", `server ${id}: unknown field ${JSON.stringify(k)} (a registration holds label, sshHost, workspace, oatsPath, path, workspaceKey and nothing else — never keys or passwords)`);
153
185
  }
154
186
  return true;
155
187
  }
188
+ const WORKSPACE_KEY_RE = /^[^\s\x00-\x1f\x7f]+$/;
189
+
190
+ /** The workspace key a host deployment reports for itself (`status --json` → `workspace.key`,
191
+ * feature workspace-identity): `{ key }`, or `{ key: null, why }` when the host answers none
192
+ * (no deployment there, a kernel before 0.38, a failed status). ssh failures throw. */
193
+ export function reportedWorkspaceKey(target, io = {}) {
194
+ const status = runRemote(target, ["status", "--json", "--dir", target.workspace], { ...io, input: undefined }).envelope;
195
+ return workspaceKeyOfStatus(status);
196
+ }
197
+ export function workspaceKeyOfStatus(status) {
198
+ if (!status.ok) return { key: null, why: `the host's status failed: ${status.error?.message || status.error?.code || "no reason given"}` };
199
+ const key = status.result?.workspace?.key;
200
+ if (typeof key === "string" && WORKSPACE_KEY_RE.test(key)) return { key };
201
+ return { key: null, why: "the host's oats reports no workspace key (no deployment at that path, or a kernel before 0.38)" };
202
+ }
203
+
204
+ /** A recorded key the host contradicts: never rewritten, the remedy named. */
205
+ export function workspaceMismatch(id, server, reported) {
206
+ return Object.assign(serverError("E_SERVER_WORKSPACE_MISMATCH", `server ${id} is registered for workspace ${server.workspaceKey}, but the deployment at ${server.sshHost}:${server.workspace} reports ${reported}; the registration was not changed (register the deployment you mean with oats server add ${id} --replace …)`), { details: { recorded: server.workspaceKey, reported } });
207
+ }
156
208
 
157
209
  export function getServer(id) {
158
210
  const servers = readServers();
@@ -261,6 +313,7 @@ export function runRemote(target, oatsArgs, io = {}) {
261
313
  let stderr = "";
262
314
  let status = 0;
263
315
  let started = true;
316
+ let timedOut = false;
264
317
  try {
265
318
  // `io.input` (a Buffer) streams to the remote command's stdin: the only
266
319
  // way bytes reach a host, as a quoted argument never could.
@@ -273,7 +326,11 @@ export function runRemote(target, oatsArgs, io = {}) {
273
326
  status = typeof e.status === "number" ? e.status : 255;
274
327
  // Neither an exit status nor a signal: ssh never started (not installed, not executable).
275
328
  started = typeof e.status === "number" || !!e.signal;
329
+ timedOut = e.code === "ETIMEDOUT";
276
330
  }
331
+ // The call's own deadline killed ssh: whatever it printed is cut short. Still ssh's failure to the
332
+ // envelope (E_SSH); `timedOut` lets a caller with a budget tell it from a broken link.
333
+ if (timedOut) throw Object.assign(serverError("E_SSH", `ssh to ${target.sshHost} did not finish within ${Math.round((io.timeoutMs || 300000) / 1000)} s`), { timedOut: true });
277
334
  if (String(stdout).trim()) {
278
335
  const envelope = parseRemoteEnvelope(stdout);
279
336
  // A bare `retire --json` answer with cleanup still owed exits 1 on the
@@ -1050,3 +1107,264 @@ export function attachArgv(serverId, { instance, home } = {}, io = {}) {
1050
1107
  // -t: allocate a PTY; placed before `--` with the other ssh options.
1051
1108
  return { argv: [bin, "-t", ...rest], target, home: remoteHome };
1052
1109
  }
1110
+
1111
+ // ------------------------------------------------------------------ connect
1112
+
1113
+ /** `oats server connect` (docs/servers.md): this workspace's deployment on a host, registered, in
1114
+ * one idempotent run of six steps, each re-checking its state before doing anything:
1115
+ * ssh → oats → git → deployment → register → readiness. A step answers ok (already so), done
1116
+ * (this run did it), needs-human (with the exact remedy: later steps are skipped, waiting for
1117
+ * it) or failed (the run ends: the error carries the steps so far). Nothing here touches
1118
+ * credentials: what git on the host cannot read is a human step. */
1119
+ export const CONNECT_STEPS = ["ssh", "oats", "git", "deployment", "register", "readiness"];
1120
+ /** The readiness step's whole allowance (all souls, one control connection). */
1121
+ export const CONNECT_READINESS_BUDGET_MS = 60000;
1122
+ const INSTALL_TIMEOUT_MS = 600000;
1123
+
1124
+ /** One host command that is not the oats CLI (`true`, `npm`): its exit status and output, never an
1125
+ * envelope. The registration's --path applies as for oats; stdin is closed. */
1126
+ function runHostCommand(target, argv, io = {}) {
1127
+ const exec = io.execFileSync || execFileSync;
1128
+ const [bin, ...rest] = sshArgv({ ...target, oatsPath: argv[0] }, argv.slice(1), { control: controlUsable(target, io) });
1129
+ try {
1130
+ return { status: 0, stdout: String(exec(bin, rest, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 16 * 1024 * 1024, timeout: io.timeoutMs || 300000 }) ?? ""), stderr: "" };
1131
+ } catch (e) {
1132
+ return { status: typeof e.status === "number" ? e.status : 255, stdout: String(e.stdout || ""), stderr: String(e.stderr ?? e.message ?? "") };
1133
+ }
1134
+ }
1135
+ const tail = (text) => String(text || "").trim().slice(-400) || "(no output)";
1136
+
1137
+ /** A command a person copies out of a remedy: every argument quoted for a POSIX shell (remoteQuote),
1138
+ * so a value from a workspace reference or a host path is one literal argument and never shell
1139
+ * code, inside a Markdown code span no backtick in the command can end (the Desktop's copy buttons
1140
+ * read the spans). */
1141
+ /** A value written into a remedy's prose (a reference, a host's message): its backslashes escaped and
1142
+ * then its backticks, so no backtick in it is left active (a backslash of its own before one would
1143
+ * otherwise escape the escape) and it never opens a code span a copy button would offer as a command. */
1144
+ const inProse = (value) => String(value).replace(/\\/g, "\\\\").replace(/`/g, "\\`");
1145
+ export function runnable(argv) {
1146
+ const command = argv.map(remoteQuote).join(" ");
1147
+ const fence = "`".repeat(Math.max(0, ...(command.match(/`+/g) || []).map((run) => run.length)) + 1);
1148
+ const pad = command.startsWith("`") || command.endsWith("`") ? " " : "";
1149
+ return `${fence}${pad}${command}${pad}${fence}`;
1150
+ }
1151
+ const sshFailed = (target, r) => serverError("E_SSH", `ssh to ${target.sshHost} failed: ${tail(r.stderr)}`);
1152
+
1153
+ /** The oats a host answers at the target's binary: `{ version, features }`, or `{ missing: true }` when
1154
+ * its shell finds nothing there to run (exit 126/127). */
1155
+ function probeHostOats(target, io) {
1156
+ const r = runHostCommand(target, [target.oatsPath, "version", "--json"], { ...io, timeoutMs: 60000 });
1157
+ if (r.status === 255) throw sshFailed(target, r);
1158
+ if (r.status === 126 || r.status === 127) return { missing: true };
1159
+ const probe = parseRemoteEnvelope(r.stdout).result || {};
1160
+ if (probe.desktopApi !== 1 || typeof probe.version !== "string") throw serverError("E_REMOTE_INCOMPATIBLE", `${target.oatsPath} at ${target.sshHost} answered an unknown version payload: ${JSON.stringify(probe).slice(0, 200)}`);
1161
+ return { version: probe.version, features: Array.isArray(probe.features) ? probe.features.map(String) : [] };
1162
+ }
1163
+
1164
+ /** A host's failure envelope as a thrown error with its own code, message and details. */
1165
+ function relayedFailure(envelope, what) {
1166
+ const e = serverError(envelope.error?.code || "E_REMOTE", envelope.error?.message || `${what} failed on the host`);
1167
+ if (envelope.error?.details) e.details = envelope.error.details;
1168
+ return e;
1169
+ }
1170
+
1171
+ /**
1172
+ * options: { id, sshHost, workspaceRef, dir (absolute, or ~/… resolved by the host), oatsPath?, path?,
1173
+ * label?, installOats?, replace?, localVersion (this kernel's: the version installed) }
1174
+ * io: { execFileSync, now, readinessBudgetMs } (the transport and clock; tests inject them).
1175
+ * → { id, ready, registration, steps, human }; a failed step throws its code with
1176
+ * details { ...the step's details, steps }.
1177
+ */
1178
+ export function connectServer(options, io = {}) {
1179
+ const { id, sshHost, workspaceRef, dir, oatsPath, path, label, installOats = false, replace = false, localVersion } = options;
1180
+ const now = io.now || Date.now;
1181
+ validateServer(id, { sshHost, workspace: "/", ...(oatsPath ? { oatsPath } : {}), ...(path ? { path } : {}), ...(label ? { label } : {}) });
1182
+ if (typeof dir !== "string" || !(dir === "~" || dir.startsWith("~/") || dir.startsWith("/"))) throw serverError("E_BAD_ARGS", `--dir must be an absolute path or ~/… on the host (got ${JSON.stringify(dir)})`);
1183
+ io = { ...io, serverId: id, input: undefined };
1184
+ const target = { sshHost, workspace: dir, oatsPath: oatsPath || "oats", ...(path ? { path } : {}) };
1185
+ const steps = [], human = [];
1186
+ let blockedBy = null, check = null, reportedKey = null, registration = null, registeredAs = id;
1187
+
1188
+ const run = (name, fn) => {
1189
+ if (blockedBy) { steps.push({ step: name, status: "skipped", detail: `waits for ${blockedBy}` }); return; }
1190
+ let out;
1191
+ try { out = fn(); }
1192
+ catch (e) { out = { status: "failed", code: e.code || "E_CONNECT", detail: e.message, ...(e.details ? { details: e.details } : {}) }; }
1193
+ const { lines, ...step } = { step: name, ...out };
1194
+ steps.push(step);
1195
+ if (step.status === "needs-human") { human.push(...(lines || [step.remedy])); blockedBy = name; }
1196
+ if (step.status === "failed") throw Object.assign(serverError(step.code, step.detail), { details: { ...(step.details || {}), steps } });
1197
+ };
1198
+
1199
+ run("ssh", () => {
1200
+ const r = runHostCommand(target, ["true"], { ...io, timeoutMs: 60000 });
1201
+ if (r.status !== 0) throw sshFailed(target, r);
1202
+ return { status: "ok" };
1203
+ });
1204
+
1205
+ // A kernel the rest of connect can use: this version or newer AND advertising server-connect (the
1206
+ // commands connect runs there); a build of the same version without them is not taken for one.
1207
+ run("oats", () => {
1208
+ const found = probeHostOats(target, io);
1209
+ const older = (p) => compareSemver(p.version, localVersion) < 0;
1210
+ const usable = (p) => !!p.version && !older(p) && p.features.includes("server-connect");
1211
+ if (usable(found)) return { status: "ok", detail: `oats ${found.version}${found.version === localVersion ? "" : ` (this kernel ${localVersion})`}` };
1212
+ const npm = runHostCommand(target, ["npm", "--version"], { ...io, timeoutMs: 60000 });
1213
+ if (npm.status === 255) throw sshFailed(target, npm);
1214
+ const what = !found.version ? `no oats at ${target.oatsPath} on ${sshHost}`
1215
+ : older(found) ? `oats ${found.version} at ${target.oatsPath} is older than this kernel (${localVersion})`
1216
+ : `oats ${found.version} at ${target.oatsPath} on ${sshHost} does not advertise server-connect, which connect needs there`;
1217
+ if (npm.status !== 0) return { status: "needs-human", detail: `${what}, and no npm on its PATH`, remedy: `install Node.js 22+ (npm) on ${sshHost}, or pass --path <the directory holding npm>` };
1218
+ const install = ["npm", "install", "-g", `@awebai/oats@${localVersion}`];
1219
+ if (!installOats) return { status: "needs-human", detail: what, remedy: `on ${sshHost}: ${runnable(install)} (or pass --install-oats)` };
1220
+ const r = runHostCommand(target, install, { ...io, timeoutMs: INSTALL_TIMEOUT_MS });
1221
+ if (r.status === 255) throw sshFailed(target, r);
1222
+ if (r.status !== 0) throw serverError("E_REMOTE_INSTALL", `${install.join(" ")} failed on ${sshHost} (exit ${r.status}): ${tail(r.stderr || r.stdout)}`);
1223
+ const after = probeHostOats(target, io);
1224
+ if (!usable(after)) {
1225
+ const still = !after.version ? "is still not found" : older(after) ? `still answers ${after.version}` : `still does not advertise server-connect (${after.version})`;
1226
+ throw serverError("E_REMOTE_INSTALL", `npm installed @awebai/oats@${localVersion} on ${sshHost}, but ${target.oatsPath} there ${still}; pass --oats <the installed oats> or --path <npm's global bin directory>`);
1227
+ }
1228
+ const was = !found.version ? "was missing" : older(found) ? `was ${found.version}` : `was ${found.version} without server-connect`;
1229
+ return { status: "done", detail: `installed @awebai/oats ${localVersion} (${was})` };
1230
+ });
1231
+
1232
+ // The host's own answer about the directory and the workspace remote (onboard --check, read-only).
1233
+ run("git", () => {
1234
+ const { envelope } = runRemote(target, ["onboard", dir, "--workspace", workspaceRef, "--check", "--json"], io);
1235
+ if (!envelope.ok) throw relayedFailure(envelope, "onboard --check");
1236
+ check = envelope.result;
1237
+ if (check.remote.readable) return { status: "ok", detail: `${check.workspace.key} readable at ${String(check.remote.commit).slice(0, 12)}` };
1238
+ const err = check.remote.error || {};
1239
+ const remedy = err.remedy || `make ${inProse(check.workspace.ref)} readable by git there (check the reference; give git there credentials or an SSH key for it), then confirm with ${runnable(["git", "ls-remote", check.workspace.url])}`;
1240
+ return { status: "needs-human", code: err.code || "E_REMOTE_UNREADABLE", detail: err.message, remedy: `on ${sshHost}: ${remedy}`, ...(err.hint ? { hint: err.hint } : {}) };
1241
+ });
1242
+
1243
+ run("deployment", () => {
1244
+ const abs = check.dir, want = check.workspace.key;
1245
+ const keyAt = () => reportedWorkspaceKey({ ...target, workspace: abs }, io).key;
1246
+ if (check.state === "deployment") {
1247
+ const key = keyAt();
1248
+ if (key !== want) throw Object.assign(serverError("E_SERVER_WORKSPACE_MISMATCH", `${abs} on ${sshHost} is a deployment of ${key ?? "a workspace it does not report"}, not of ${want}; nothing was changed (choose another --dir)`), { details: { expected: want, reported: key ?? null, dir: abs } });
1249
+ reportedKey = key;
1250
+ return { status: "ok", detail: `${abs} realizes ${key}` };
1251
+ }
1252
+ if (check.state === "not-empty" || check.state === "not-a-directory") {
1253
+ throw Object.assign(serverError("E_DIR_NOT_EMPTY", `${abs} on ${sshHost} ${check.state === "not-empty" ? "is not empty and holds no oats-local.yaml" : "exists and is not a directory"}; nothing was written there (choose another --dir)`), { details: { dir: abs, state: check.state } });
1254
+ }
1255
+ // The only place onboard runs on a host.
1256
+ const { envelope } = runRemote(target, ["onboard", abs, "--workspace", workspaceRef, "--json"], { ...io, timeoutMs: INSTALL_TIMEOUT_MS });
1257
+ if (!envelope.ok) throw relayedFailure(envelope, "onboard");
1258
+ reportedKey = keyAt();
1259
+ if (reportedKey !== want) throw Object.assign(serverError("E_SERVER_WORKSPACE_MISMATCH", `onboarded ${abs} on ${sshHost}, but it reports ${reportedKey ?? "no workspace key"}, not ${want}`), { details: { expected: want, reported: reportedKey ?? null, dir: abs } });
1260
+ return { status: "done", detail: `onboarded ${abs} into ${reportedKey}` };
1261
+ });
1262
+
1263
+ // One guarded update of the registry as it is now. The requested id comes first: an id registered for
1264
+ // another target is refused (or replaced), never quietly answered by a twin that serves this target.
1265
+ run("register", () => updateServers((servers) => {
1266
+ const abs = check.dir;
1267
+ const entry = { sshHost, workspace: abs, ...(oatsPath ? { oatsPath } : {}), ...(path ? { path } : {}), ...(label ? { label } : {}), workspaceKey: reportedKey };
1268
+ const sameTarget = (s) => s.sshHost === sshHost && s.workspace === abs;
1269
+ /** A registration of this very target: its key confirmed, or recorded; never contradicted. */
1270
+ const keep = (regId, detail) => {
1271
+ const existing = servers[regId];
1272
+ if (existing.workspaceKey && existing.workspaceKey !== reportedKey) throw workspaceMismatch(regId, existing, reportedKey);
1273
+ registeredAs = regId;
1274
+ registration = existing;
1275
+ if (existing.workspaceKey) return { status: "ok", ...(detail ? { detail } : {}) };
1276
+ existing.workspaceKey = reportedKey;
1277
+ return { status: "done", detail: [detail, `recorded workspace ${reportedKey}`].filter(Boolean).join("; ") };
1278
+ };
1279
+ const mine = servers[id];
1280
+ if (mine && sameTarget(mine)) {
1281
+ const differ = ["oatsPath", "path", "label"].filter((k) => entry[k] !== undefined && entry[k] !== mine[k]);
1282
+ if (!differ.length || !replace) return keep(id, differ.length ? `${differ.join(", ")} differ from the registration and were not changed (--replace rewrites it)` : undefined);
1283
+ if (mine.workspaceKey && mine.workspaceKey !== reportedKey) throw workspaceMismatch(id, mine, reportedKey);
1284
+ servers[id] = entry; registration = entry;
1285
+ return { status: "done", detail: `updated ${differ.join(", ")}` };
1286
+ }
1287
+ if (mine && !replace) throw Object.assign(serverError("E_SERVER_EXISTS", `server ${id} is already registered for ${mine.sshHost}:${mine.workspace} (pass --replace to point it at ${sshHost}:${abs}; instances spawned through it keep their saved routes)`), { details: { registered: { sshHost: mine.sshHost, workspace: mine.workspace } } });
1288
+ const twin = Object.keys(servers).find((other) => other !== id && sameTarget(servers[other]));
1289
+ if (twin && !mine) return keep(twin, `${sshHost}:${abs} is already registered as ${twin}; not registered again`);
1290
+ validateServer(id, entry);
1291
+ servers[id] = entry; registration = entry;
1292
+ return { status: "done", detail: [mine ? `replaced ${mine.sshHost}:${mine.workspace}` : `registered ${id}`, twin ? `${sshHost}:${abs} is also registered as ${twin}` : null].filter(Boolean).join("; ") };
1293
+ }));
1294
+
1295
+ // Every soul the host deployment lists (disabled ones skipped), one routed readiness each, within one
1296
+ // budget; each failing or unknown required item is a human line, said once for the souls sharing it.
1297
+ run("readiness", () => {
1298
+ const regTarget = targetOf(registration);
1299
+ const budget = io.readinessBudgetMs || CONNECT_READINESS_BUDGET_MS;
1300
+ const started = now();
1301
+ const left = () => budget - (now() - started);
1302
+ const later = `run \`oats readiness --soul <s> --server ${registeredAs}\``;
1303
+ // The budget's deadline cut the call short: a fact about time, not a failure. A broken link fails.
1304
+ const withinBudget = (args) => {
1305
+ try { return runRemote(regTarget, args, { ...io, timeoutMs: Math.max(1000, left()) }).envelope; }
1306
+ catch (e) { if (e.timedOut) return null; throw e; }
1307
+ };
1308
+ const listed = withinBudget(["souls", "--json", "--dir", regTarget.workspace]);
1309
+ if (!listed) return { status: "needs-human", detail: "readiness not checked", remedy: `readiness not checked: the host did not list its souls within ${Math.round(budget / 1000)} s; ${later} for each soul` };
1310
+ if (!listed.ok) return { status: "needs-human", detail: "the host could not list its souls", remedy: `on ${sshHost}: ${runnable(["oats", "souls", "--dir", regTarget.workspace])} fails: ${inProse(listed.error?.message || listed.error?.code)}` };
1311
+ const souls = (listed.result?.souls || []).filter((s) => s.problem?.code !== "E_SOUL_DISABLED").map((s) => s.qualifiedName || s.name);
1312
+ const problems = new Map(), unchecked = [];
1313
+ const add = (text, soul) => { if (!problems.has(text)) problems.set(text, []); if (!problems.get(text).includes(soul)) problems.get(text).push(soul); };
1314
+ for (const soul of souls) {
1315
+ if (unchecked.length || left() < 1000) { unchecked.push(soul); continue; }
1316
+ const envelope = withinBudget(["readiness", "--soul", soul, "--dir", regTarget.workspace, "--json"]);
1317
+ if (!envelope) { unchecked.push(soul); continue; }
1318
+ if (!envelope.ok) { add(`readiness failed: ${envelope.error?.message || envelope.error?.code}`, soul); continue; }
1319
+ for (const check of Object.values(envelope.result?.checks || {})) {
1320
+ for (const item of check.items || []) {
1321
+ if (item.required && (item.status === "fail" || item.status === "unknown")) add(`${item.subject}: ${item.reason ?? item.status}${item.remedy ? ` → ${item.remedy}` : ""}`, soul);
1322
+ }
1323
+ }
1324
+ }
1325
+ const lines = [...problems].map(([text, by]) => `${by.join(", ")}: ${text}`);
1326
+ if (unchecked.length) lines.push(`readiness not checked for ${unchecked.length} soul${unchecked.length === 1 ? "" : "s"}: ${later} for ${unchecked.join(", ")}`);
1327
+ if (!lines.length) return { status: "ok", detail: souls.length ? `${souls.length} soul${souls.length === 1 ? "" : "s"} ready` : "no souls listed" };
1328
+ return { status: "needs-human", detail: `${lines.length} readiness problem${lines.length === 1 ? "" : "s"}`, remedy: lines.join("\n"), lines };
1329
+ });
1330
+
1331
+ // `registeredAs`: the id that serves the target (another id's registration when it was already there).
1332
+ return { id, ready: !steps.some((s) => s.status === "needs-human" || s.status === "failed"), registration, steps, human, registeredAs };
1333
+ }
1334
+
1335
+ // ------------------------------------------------------- capability routing
1336
+
1337
+ /** The routing flag of a capability command: the first `--server <id>` or `--server=<id>` before any
1338
+ * `--` (after it, the argv is the provider's), taken out → `{ id, argv }`, or null when there is none.
1339
+ * `id` is true when the flag has no value. */
1340
+ export function serverFlagOf(argv) {
1341
+ const end = argv.indexOf("--");
1342
+ const head = end < 0 ? argv.length : end;
1343
+ for (let i = 0; i < head; i++) {
1344
+ if (argv[i] === "--server") {
1345
+ const value = i + 1 < head && !argv[i + 1].startsWith("--") ? argv[i + 1] : true;
1346
+ return { id: value, argv: [...argv.slice(0, i), ...argv.slice(i + (value === true ? 1 : 2))] };
1347
+ }
1348
+ if (argv[i].startsWith("--server=")) return { id: argv[i].slice("--server=".length) || true, argv: [...argv.slice(0, i), ...argv.slice(i + 1)] };
1349
+ }
1350
+ return null;
1351
+ }
1352
+
1353
+ /** An argv safe to print: every `--invite <v>` / `--invite=<v>` value replaced. */
1354
+ export function redactArgv(argv) {
1355
+ return argv.map((a, i) => (argv[i - 1] === "--invite" ? "<redacted>" : a.startsWith("--invite=") ? "--invite=<redacted>" : a));
1356
+ }
1357
+
1358
+ /** `oats <namespace> <command> … --server <id>` on the host: the same argv (the routing flag already
1359
+ * taken out) run in the registration's workspace, where the host's capability dispatch finds its
1360
+ * deployment. Stdin goes to the host as it is, never read here, unless it is a terminal: then nothing
1361
+ * is forwarded and the host's stdin is closed. stdout is relayed by the caller under --json (held, to
1362
+ * relay the host's envelope verbatim) and inherited otherwise; stderr is always inherited.
1363
+ * → { status, stdout } (stdout only under json). */
1364
+ export function routeCapability(serverId, argv, { server, spawnSync: spawn = spawnSync, stdinIsTTY = process.stdin.isTTY, json = false } = {}) {
1365
+ const target = targetOf(server);
1366
+ const [bin, ...rest] = sshArgv(target, argv, { cwd: target.workspace, control: controlUsable(target, { serverId }) });
1367
+ const r = spawn(bin, rest, { stdio: [stdinIsTTY ? "ignore" : "inherit", json ? "pipe" : "inherit", "inherit"], maxBuffer: 64 * 1024 * 1024 });
1368
+ if (r.error) throw serverError("E_SSH", `cannot run ssh to ${target.sshHost} for \`oats ${redactArgv(argv).join(" ")}\` on server ${serverId}: ${r.error.message}`);
1369
+ return { status: typeof r.status === "number" ? r.status : 255, ...(json ? { stdout: r.stdout } : {}) };
1370
+ }
@@ -8,7 +8,7 @@
8
8
  },
9
9
  "oats.aweb": {
10
10
  "url": "https://github.com/awebai/oats-aweb.git",
11
- "ref": "v1.20.0",
11
+ "ref": "v1.21.1",
12
12
  "path": "oats-package"
13
13
  },
14
14
  "oats.jira": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awebai/oats",
3
- "version": "0.38.2",
3
+ "version": "0.39.1",
4
4
  "description": "OATS (Open Agent Team Specification) — durable souls, disposable instances, targetable capability packages, and the runtime-neutral oats CLI/kernel.",
5
5
  "keywords": [
6
6
  "agents",