@awebai/oats 0.22.17 → 0.22.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/servers.mjs CHANGED
@@ -252,7 +252,8 @@ export function checkRemoteSupport(remote, target, oatsArgs, roster) {
252
252
  // capability-defined agent, or one with no live instance) has no default
253
253
  // this side can establish: the remote kernel validates its own default at
254
254
  // spawn, and nothing is asserted here about it.
255
- const runtime = flagOf("--runtime") || soul?.runtime;
255
+ const namedConfig = oatsArgs.includes("--launch-config");
256
+ const runtime = flagOf("--runtime") || (namedConfig ? undefined : soul?.runtime);
256
257
  // What the message may claim depends on what was established: an
257
258
  // advertising remote said what it supports; a silent one (before 0.22.2)
258
259
  // said nothing, and only pi and claude are assumed of it.
@@ -260,6 +261,7 @@ export function checkRemoteSupport(remote, target, oatsArgs, roster) {
260
261
  ? `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"}`
261
262
  : `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`;
262
263
  const refuse = (what) => { throw serverError("E_REMOTE_INCOMPATIBLE", `remote oats ${remote.version} at ${target.sshHost}: ${what} was not established as supported there (${supports})`); };
264
+ if (namedConfig && !remote.features.includes("launch-config")) refuse("a named launch configuration (the host must advertise launch-config)");
263
265
  if (runtime && !remote.runtimes.includes(runtime)) refuse(`runtime ${runtime}${flagOf("--runtime") ? "" : ` (the default of soul ${agent} there)`}`);
264
266
  // A wake schedule at spawn is saved by the host: it must advertise schedules.
265
267
  if (["--wake-json", "--wake-every", "--wake-cron"].some((f) => oatsArgs.includes(f)) && !remote.features.includes("schedule")) refuse("a wake schedule at spawn (the host must advertise the schedule feature)");
@@ -656,17 +658,100 @@ const OPERATIONS_COMMANDS = new Set(["inspect", "operation", "use", "soul"]);
656
658
  /** `session start` on the execution host for a remote instance: the same
657
659
  * route resolution as inspect, refused before any mutation when the remote
658
660
  * kernel does not advertise session-start, the envelope relayed as is. */
659
- export function startRemote(serverId, { instance, home, model } = {}, io = {}) {
660
- const route = resolveRoute(serverId, { instance, home }, "session start");
661
+ export function startRemote(serverId, choices = {}, io = {}) {
662
+ return launchRemoteSession(serverId, "start", choices, io);
663
+ }
664
+
665
+ export function restartRemote(serverId, choices = {}, io = {}) {
666
+ return launchRemoteSession(serverId, "restart", choices, io);
667
+ }
668
+
669
+ function remoteLaunchArgs({ launchConfig, runtime, model, yolo } = {}) {
670
+ const args = [];
671
+ if (launchConfig !== undefined) {
672
+ if (typeof launchConfig !== "string" || !/^[a-z0-9][a-z0-9._-]{0,63}$/i.test(launchConfig)) throw serverError("E_BAD_ARGS", "invalid launch configuration name");
673
+ args.push("--launch-config", launchConfig);
674
+ }
675
+ if (runtime !== undefined) {
676
+ if (!["pi", "claude", "codex"].includes(runtime)) throw serverError("E_BAD_ARGS", "runtime must be pi, claude or codex");
677
+ args.push("--runtime", runtime);
678
+ }
679
+ if (model !== undefined && model !== null && model !== "") {
680
+ if (typeof model !== "string" || model.startsWith("-") || model.includes("\0")) throw serverError("E_BAD_ARGS", "invalid model name");
681
+ args.push("--model", model);
682
+ }
683
+ if (yolo !== undefined) {
684
+ if (typeof yolo !== "boolean") throw serverError("E_BAD_ARGS", "yolo must be true or false");
685
+ args.push(yolo ? "--yolo" : "--no-yolo");
686
+ }
687
+ return args;
688
+ }
689
+
690
+ function requireRemoteFeature(remote, target, feature) {
691
+ if (!remote.features.includes(feature)) throw serverError("E_REMOTE_INCOMPATIBLE", `remote oats ${remote.version} at ${target.sshHost} does not advertise ${feature}; upgrade it there; nothing was sent`);
692
+ }
693
+
694
+ function launchRemoteSession(serverId, action, choices, io) {
695
+ const { instance, home, launchConfig, runtime, yolo } = choices;
696
+ const choiceArgs = remoteLaunchArgs(choices);
697
+ const route = resolveRoute(serverId, { instance, home }, `session ${action}`);
661
698
  const remote = requireSessionRemote(route.target, io);
662
699
  if (!remote.features.includes("session-start")) {
663
700
  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`);
664
701
  }
665
- const args = ["session", "start", "--home", route.home, ...(model ? ["--model", String(model)] : []), "--json"];
702
+ if (action === "restart") requireRemoteFeature(remote, route.target, "session-restart");
703
+ if (launchConfig !== undefined || runtime !== undefined || yolo !== undefined) requireRemoteFeature(remote, route.target, "launch-config");
704
+ const args = ["session", action, "--home", route.home, ...choiceArgs, "--json"];
666
705
  const { envelope, stderr } = runRemote(route.target, args, io);
667
706
  return { envelope: envelope.ok ? { ...envelope, result: { ...envelope.result, server: serverId, instance: instance || route.snapshot?.instance || envelope.result.instance } } : envelope, stderr, route };
668
707
  }
669
708
 
709
+ /** Scope operations follow the registration; existing-home operations follow
710
+ * its saved route. Definitions travel on stdin, never as remote file paths or
711
+ * shell arguments. The host resolves environment references and validates the
712
+ * definition; this client never substitutes its own environment. */
713
+ export function launchConfigRemote(serverId, options = {}, io = {}) {
714
+ const { action, name, context, instance, home, soul, agentsRoot, definition, keepEnv } = options;
715
+ if (!["list", "set", "remove", "preview"].includes(action)) throw serverError("E_BAD_ARGS", "unknown launch configuration action");
716
+ const write = action === "set" || action === "remove";
717
+ const homeSelected = home !== undefined || instance !== undefined;
718
+ if (homeSelected && (context !== undefined || soul !== undefined || agentsRoot !== undefined)) throw serverError("E_BAD_ARGS", "select one home or configuration scope");
719
+ if (write && (homeSelected || soul !== undefined || agentsRoot !== undefined)) throw serverError("E_BAD_ARGS", "edit a launch configuration with --dir, not a home or soul");
720
+ if (action === "preview" && !homeSelected && !soul) throw serverError("E_BAD_ARGS", "select a home or soul to preview");
721
+ const args = ["launch-config", action];
722
+ if (write) {
723
+ if (typeof name !== "string" || !/^[a-z0-9][a-z0-9._-]{0,63}$/i.test(name)) throw serverError("E_BAD_ARGS", "invalid launch configuration name");
724
+ args.push(name);
725
+ }
726
+ let input;
727
+ if (action === "set") {
728
+ if (!definition || typeof definition !== "object" || Array.isArray(definition)) throw serverError("E_BAD_ARGS", "specify a launch configuration object");
729
+ if (keepEnv !== undefined && typeof keepEnv !== "boolean") throw serverError("E_BAD_ARGS", "keepEnv must be true or false");
730
+ if (keepEnv && Object.hasOwn(definition, "env")) throw serverError("E_BAD_ARGS", "omit env when preserving the saved environment");
731
+ input = Buffer.from(JSON.stringify(definition));
732
+ args.push("--file", "-");
733
+ if (keepEnv) args.push("--keep-env");
734
+ }
735
+ const add = (flag, value, absolute = false) => {
736
+ if (typeof value !== "string" || !value || value.startsWith("-") || value.includes("\0") || (absolute && !value.startsWith("/"))) throw serverError("E_BAD_ARGS", `invalid ${flag}`);
737
+ args.push(flag, value);
738
+ };
739
+ // Validate explicit selectors before even probing the host.
740
+ if (context !== undefined) add("--dir", context, true);
741
+ if (soul !== undefined) add("--soul", soul);
742
+ if (agentsRoot !== undefined) add("--agents-root", agentsRoot, true);
743
+ if (action === "preview") args.push(...remoteLaunchArgs(options));
744
+ const route = homeSelected ? resolveRoute(serverId, { instance, home }, "launch-config") : undefined;
745
+ const target = route?.target || targetOf(io.server || getServer(serverId));
746
+ if (route) add("--home", route.home, true);
747
+ else if (context === undefined) args.push("--dir", target.workspace);
748
+ // stdin belongs only to the set operation, never its preceding version probe.
749
+ const remote = checkRemote(target, { ...io, input: undefined });
750
+ requireRemoteFeature(remote, target, "launch-config");
751
+ const { envelope, stderr } = runRemote(target, [...args, "--json"], { ...io, input });
752
+ return { envelope, stderr, target, ...(route ? { route } : {}) };
753
+ }
754
+
670
755
  /** `oats schedule ...` on the execution host: schedules are host-owned, so
671
756
  * every subcommand runs in the server's registered workspace; refused
672
757
  * before any remote mutation when the remote kernel does not advertise
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awebai/oats",
3
- "version": "0.22.17",
3
+ "version": "0.22.19",
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",
@@ -160,9 +160,30 @@ function log(...parts) {
160
160
 
161
161
  /** Take the root's single-run lock, or say who holds it. A hook-triggered
162
162
  * pass that finds it held exits 0: the holder's pass, or the next one,
163
- * reconciles the same sessions. */
163
+ * reconciles the same sessions.
164
+ *
165
+ * `fn` must return or throw, never call process.exit: the lock is released
166
+ * in the finally, and a release that did not happen is said so on stderr
167
+ * with the operator recovery and a nonzero exit status. Likewise an owner
168
+ * record that could not be written: the lock never silently outlives the
169
+ * pass that created it. */
164
170
  function withCaptureLock(fn) {
165
- const lock = acquireCaptureLock(root);
171
+ let lock;
172
+ try {
173
+ lock = acquireCaptureLock(root);
174
+ } catch (err) {
175
+ if (err.lockCleanup) {
176
+ const c = err.lockCleanup;
177
+ const outcome = c.removed ? "the initializing lock was removed"
178
+ : c.reason === "gone" ? "the initializing lock was already gone (removed by another party)"
179
+ : c.reason === "replaced" ? (c.owner
180
+ ? `the lock now belongs to pid ${c.owner.pid} (started ${c.owner.startedAt || "?"}) and was left alone`
181
+ : "the lock directory changed or its identity could not be verified, no owner record was readable, and it was left alone")
182
+ : `the initializing lock could NOT be removed${c.error ? ` (${c.error})` : ""}; ${c.recovery}`;
183
+ console.error(`capture: could not write the owner record of ${c.path}: ${err.message}; ${outcome}`);
184
+ }
185
+ throw err;
186
+ }
166
187
  if (lock.held) {
167
188
  // Never quiet: a stale lock after a killed pass needs the operator, and
168
189
  // the line says exactly what to check and what to remove.
@@ -170,13 +191,29 @@ function withCaptureLock(fn) {
170
191
  if (lock.held.liveness === "alive") log(line); else console.error(line);
171
192
  return { appended: 0, skipped: true };
172
193
  }
173
- try { return fn(); } finally { lock.release(); }
194
+ try {
195
+ return fn();
196
+ } finally {
197
+ const r = lock.release();
198
+ if (!r.released) {
199
+ // Say what was observed: gone, unreadable (unknown), another owner, or
200
+ // our own lock that would not go away; never a guess about liveness.
201
+ const detail = r.reason === "gone" ? "it was already removed by another party (an operator recovery?); nothing to release"
202
+ : r.reason === "not-owner" ? `it now belongs to pid ${r.owner.pid} (started ${r.owner.startedAt || "?"}); left alone`
203
+ : r.reason === "unknown-owner" ? `its owner record is missing or unreadable, so it may be an operator removal in progress or a newer pass initializing; left alone; ${r.recovery}`
204
+ : `${r.error ? `${r.error}; ` : ""}${r.recovery}`;
205
+ console.error(`capture: did not release ${lock.path} (${r.reason}): ${detail}`);
206
+ process.exitCode = 1;
207
+ }
208
+ }
174
209
  }
175
210
 
176
211
  function pass() {
212
+ // The privacy loader fails closed by exiting; it runs before the lock is
213
+ // taken so that exit never leaves the lock behind.
214
+ const ignore = loadIgnoreOrExit(root);
177
215
  return withCaptureLock(() => {
178
216
  const out = { appended: 0 };
179
- const ignore = loadIgnoreOrExit(root);
180
217
  if (!args["aw-only"]) {
181
218
  for (const r of captureAllSessions(store, { owner, ignore })) {
182
219
  out.appended += r.appended;
@@ -307,11 +344,17 @@ if (args.home) {
307
344
  });
308
345
  }
309
346
  console.log(JSON.stringify({ home: args.home, owner, appended, sessions, ...(unattributed.length ? { unattributed } : {}) }, null, 2));
310
- process.exit(0);
347
+ process.exit(process.exitCode ?? 0); // nonzero when the lock release had to be reported
311
348
  }
312
349
 
313
350
  warnOnStrangerOwner();
314
- pass();
351
+ try {
352
+ pass();
353
+ } catch (err) {
354
+ // The lock's finally has run by now; one line, then the status.
355
+ console.error(`capture pass failed: ${err.message}`);
356
+ process.exit(1);
357
+ }
315
358
 
316
359
  if (args.watch) {
317
360
  const roots = [
@@ -12,7 +12,8 @@
12
12
  // message says exactly that. (A reclaim protocol was reviewed and rejected:
13
13
  // rename is not compare-and-swap, and stealing from a stalled live
14
14
  // initializer under memory pressure is the failure we are preventing.)
15
- import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
15
+ import { closeSync, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from "node:fs";
16
+ import { randomBytes } from "node:crypto";
16
17
  import { join } from "node:path";
17
18
 
18
19
  export function captureLockPath(root) { return join(root, ".capture.lock"); }
@@ -41,8 +42,28 @@ export function recoveryInstruction(dir, owner, liveness) {
41
42
 
42
43
  /** Try to take the root's capture lock. Returns { path, release } when
43
44
  * taken, or { path, held: { pid, startedAt, liveness, recovery } } when any
44
- * lock exists. Never removes a lock it did not create. */
45
- export function acquireCaptureLock(root, { now = Date.now, pid = process.pid, liveness = holderLiveness } = {}) {
45
+ * lock exists. Never removes a lock it did not create.
46
+ *
47
+ * Two failure points are reported rather than left behind. If the owner
48
+ * record cannot be written after THIS call created the directory (a full
49
+ * disk, say), the directory is removed only while it is still the very
50
+ * directory this call created (same inode) and carries no other owner's
51
+ * record; a replacement that appeared meanwhile (operator recovery, then a
52
+ * newer pass) is left alone. The original error is rethrown with
53
+ * `lockCleanup: { path, removed, reason?, owner?, error?, recovery? }`
54
+ * saying what happened. And `release()` never throws: it answers
55
+ * `{ released: true }` only when the lock is verifiably gone, otherwise
56
+ * `{ released: false, reason: "gone" | "unknown-owner" | "not-owner" |
57
+ * "remove-failed", ... }` with the actual observation, never a guess.
58
+ *
59
+ * The owner record carries a per-acquisition nonce, so a release kept from
60
+ * an earlier acquisition cannot erase a later one by the same pid (an
61
+ * operator recovery followed by a new pass in the same long-lived process).
62
+ * That is ownership checking; no lock is ever reclaimed.
63
+ *
64
+ * `io` exists for fault injection in tests only. */
65
+ export function acquireCaptureLock(root, { now = Date.now, pid = process.pid, liveness = holderLiveness, io = {} } = {}) {
66
+ const fs = { writeFileSync, rmSync, openSync, closeSync, ...io };
46
67
  const dir = captureLockPath(root);
47
68
  mkdirSync(root, { recursive: true }); // the store creates the root lazily; the lock may come first
48
69
  try {
@@ -53,12 +74,54 @@ export function acquireCaptureLock(root, { now = Date.now, pid = process.pid, li
53
74
  const live = owner ? (owner.pid === pid ? "alive" : liveness(owner.pid)) : "unknown";
54
75
  return { path: dir, held: { pid: owner?.pid, startedAt: owner?.startedAt, liveness: live, recovery: recoveryInstruction(dir, owner, live) } };
55
76
  }
56
- writeFileSync(join(dir, "owner.json"), JSON.stringify({ pid, startedAt: new Date(now()).toISOString() }));
77
+ const nonce = randomBytes(8).toString("hex");
78
+ let directoryFd, identity;
79
+ try {
80
+ // Keep the directory alive until initialization or its cleanup finishes.
81
+ // Otherwise Linux can reuse its inode immediately after an unlink, making
82
+ // a record-less replacement look like the directory we created.
83
+ directoryFd = fs.openSync(dir, "r");
84
+ identity = fstatSync(directoryFd);
85
+ fs.writeFileSync(join(dir, "owner.json"), JSON.stringify({ pid, nonce, startedAt: new Date(now()).toISOString() }));
86
+ } catch (err) {
87
+ // Ownership was proven by the mkdir, not by the moment of cleanup: the
88
+ // directory is removed only if it is still ours (same inode) and holds
89
+ // no other pass's record. Anything else is reported, not deleted.
90
+ let cleanupError, reason;
91
+ const cur = readOwner(dir);
92
+ const same = (() => { try { const current = lstatSync(dir); return identity && current.dev === identity.dev && current.ino === identity.ino; } catch { return false; } })();
93
+ if (!existsSync(dir)) reason = "gone";
94
+ else if (!identity) reason = "unverified";
95
+ else if (!same || (cur && (cur.pid !== pid || cur.nonce !== nonce))) reason = "replaced";
96
+ else { try { fs.rmSync(dir, { recursive: true, force: true }); } catch (e2) { cleanupError = e2; } }
97
+ const removed = reason === undefined && !existsSync(dir);
98
+ err.lockCleanup = {
99
+ path: dir,
100
+ removed,
101
+ ...(reason ? { reason } : {}),
102
+ ...(reason === "replaced" && cur ? { owner: cur } : {}),
103
+ ...(cleanupError ? { error: cleanupError.message } : {}),
104
+ ...(removed || reason === "gone" || reason === "replaced" ? {} : { recovery: recoveryInstruction(dir, undefined, "unknown") }),
105
+ };
106
+ throw err;
107
+ } finally {
108
+ if (directoryFd !== undefined) fs.closeSync(directoryFd);
109
+ }
57
110
  return {
58
111
  path: dir,
59
112
  release: () => {
113
+ if (!existsSync(dir)) return { released: false, reason: "gone" };
60
114
  const cur = readOwner(dir);
61
- if (cur && cur.pid === pid) { try { rmSync(dir, { recursive: true, force: true }); } catch { /* already gone */ } }
115
+ if (!cur) return { released: false, reason: "unknown-owner", recovery: recoveryInstruction(dir, undefined, "unknown") };
116
+ if (cur.pid !== pid || cur.nonce !== nonce) return { released: false, reason: "not-owner", owner: cur };
117
+ let error;
118
+ try { fs.rmSync(dir, { recursive: true, force: true }); } catch (e) { error = e; }
119
+ if (!existsSync(dir)) return { released: true };
120
+ // Our own lock could not be removed. We are the holder and, as far as
121
+ // this process can tell, alive; the operator gets a conditional line.
122
+ const live = pid === process.pid ? "alive" : liveness(pid);
123
+ const recovery = `${dir} is still held by pid ${pid} (this pass, ${live} when it reported this); its removal failed${error ? ` (${error.message})` : ""}; once that process has exited (ps -p ${pid}), remove the lock with: rm -r -- ${shellQuote(dir)} and rerun`;
124
+ return { released: false, reason: "remove-failed", liveness: live, ...(error ? { error: error.message } : {}), recovery };
62
125
  },
63
126
  };
64
127
  }