@awebai/oats 0.40.1 → 0.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/schedule.mjs CHANGED
@@ -117,7 +117,7 @@ export const SCHEDULE_FILE_BUDGET = 1024 * 1024;
117
117
  * O_NOFOLLOW|O_NONBLOCK open → fstat dev/ino identity → whole file only if
118
118
  * within budget (an over-budget file is REFUSED as a typed error, never
119
119
  * truncated into fabricated JSON). Returns { value, source }. */
120
- function readJsonBounded(path, fallback, label) {
120
+ function readJsonBounded(path, fallback, label, { atomicReplace = false } = {}) {
121
121
  let st;
122
122
  try { st = lstatSync(path); }
123
123
  catch (e) { if (e.code === "ENOENT") return { value: fallback, source: { path: label, status: "absent", bytes: 0 } }; throw scheduleError("E_SCHEDULE_INVALID", `${path} cannot be read: ${e.message}`, { field: "file", source: { path: label, status: "refused", bytes: 0 } }); }
@@ -127,7 +127,10 @@ function readJsonBounded(path, fallback, label) {
127
127
  try {
128
128
  fd = openSync(path, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0) | (fsConstants.O_NONBLOCK ?? 0));
129
129
  const fst = fstatSync(fd);
130
- if (!fst.isFile() || fst.dev !== st.dev || fst.ino !== st.ino || fst.size > SCHEDULE_FILE_BUDGET) throw new Error("swapped or grew past budget");
130
+ // The registry is atomically replaced by concurrent writers. Its opened,
131
+ // no-follow regular-file descriptor is a coherent old/new snapshot even
132
+ // when rename changed the inode since lstat. Other readers pin identity.
133
+ if (!fst.isFile() || (!atomicReplace && (fst.dev !== st.dev || fst.ino !== st.ino)) || fst.size > SCHEDULE_FILE_BUDGET) throw new Error("swapped or grew past budget");
131
134
  const buf = Buffer.alloc(fst.size); let got = 0;
132
135
  while (got < fst.size) { const n = readSync(fd, buf, got, fst.size - got, got); if (n <= 0) break; got += n; }
133
136
  text = buf.toString("utf8"); st = fst;
@@ -208,48 +211,70 @@ export function writeState(ws, st) { recordRecentRuns(st); writeJson(statePath(w
208
211
  const DEFAULT_MAX_CONCURRENT = 5;
209
212
  const scheduleCap = (reg) => reg.maxConcurrent ?? DEFAULT_MAX_CONCURRENT;
210
213
 
211
- /** Call under registry.lock: persisted caps are explicit operator choices.
212
- * Pre-v2 registries wrote 1 even when the operator chose nothing. Migrate it
213
- * once; a subsequent explicit 1 is distinguished by capsVersion: 2. */
214
- function readRegistryLocked() {
214
+ /** Decode a bounded atomic-file snapshot without locks or filesystem writes. */
215
+ function readRegistryDocument() {
216
+ const path = join(hostScheduleDir(), "registry.json");
217
+ const reg = readJsonBounded(path, { version: 1, capsVersion: 2, tickIntervalSec: 60, workspaces: [] }, "registry.json", { atomicReplace: true }).value;
218
+ if (!reg || typeof reg !== "object" || Array.isArray(reg)) throw scheduleError("E_SCHEDULE_INVALID", `${path} must contain a registry object`, { field: "file" });
219
+ return reg;
220
+ }
221
+ const validCap = (value) => Number.isSafeInteger(value) && value >= 1;
222
+ /** Project legacy defaults in memory. Only locked mutations persist this. */
223
+ function normalizeRegistry(reg) {
215
224
  const path = join(hostScheduleDir(), "registry.json");
216
- const reg = readJson(path, { version: 1, capsVersion: 2, tickIntervalSec: 60, workspaces: [] });
217
225
  if (!Array.isArray(reg.workspaces)) reg.workspaces = [];
218
- if (reg.maxConcurrent !== undefined && (!Number.isSafeInteger(reg.maxConcurrent) || reg.maxConcurrent < 1)) delete reg.maxConcurrent;
226
+ if (reg.maxConcurrent !== undefined && !validCap(reg.maxConcurrent)) {
227
+ throw scheduleError("E_SCHEDULE_INVALID", `${path}: maxConcurrent must be a positive safe integer, or absent for the default (got ${JSON.stringify(reg.maxConcurrent)}); correct it with oats schedule host install --max-concurrent <N|default> --dir <deployment>`, { field: "maxConcurrent" });
228
+ }
219
229
  if (!Number.isInteger(reg.tickIntervalSec) || reg.tickIntervalSec < 60) reg.tickIntervalSec = 60;
220
230
  // Trigger and schedule caps are independent; absent trigger cap is unbounded.
221
- if (reg.triggersMaxConcurrent !== undefined && !(Number.isSafeInteger(reg.triggersMaxConcurrent) && reg.triggersMaxConcurrent >= 1)) {
231
+ if (reg.triggersMaxConcurrent !== undefined && !validCap(reg.triggersMaxConcurrent)) {
222
232
  throw scheduleError("E_SCHEDULE_INVALID", `${path}: triggersMaxConcurrent must be a positive integer, or absent for no host cap (got ${JSON.stringify(reg.triggersMaxConcurrent)})`, { field: "triggersMaxConcurrent" });
223
233
  }
224
234
  if ((reg.capsVersion ?? 1) < 2) {
225
235
  if (reg.maxConcurrent === 1) delete reg.maxConcurrent;
226
236
  reg.capsVersion = 2;
227
- writeRegistry(reg);
228
237
  }
229
238
  return reg;
230
239
  }
231
- export function readRegistry() { return withRegistryLock(readRegistryLocked); }
232
- /** Raw write: callers doing read-modify-write hold registry.lock. */
240
+ export function readRegistry() { return normalizeRegistry(readRegistryDocument()); }
241
+ /** Raw atomic write: no normalization or locking. Read-modify-write callers
242
+ * must hold registry.lock and reread there; readers never persist migration. */
233
243
  export function writeRegistry(reg) { writeJson(join(hostScheduleDir(), "registry.json"), reg); }
234
- export function registerWorkspace(ws, caps = {}) {
235
- for (const [field, reset] of [["maxConcurrent", "default"], ["triggersMaxConcurrent", "none"]]) {
236
- const value = caps[field];
237
- if (value !== undefined && value !== reset && !(Number.isSafeInteger(value) && value >= 1)) {
238
- throw scheduleError("E_BAD_ARGS", `${field} must be a positive safe integer or ${reset}`, { field });
239
- }
240
- }
244
+ function updateRegistry(change, caps = {}) {
241
245
  return withRegistryLock(() => {
242
- const reg = readRegistryLocked(), before = JSON.stringify(reg), abs = resolve(ws);
246
+ const raw = readRegistryDocument(), before = JSON.stringify(raw);
247
+ const legacyOne = (raw.capsVersion ?? 1) < 2 && raw.maxConcurrent === 1;
248
+ // A validated explicit replacement can repair an invalid stored schedule
249
+ // cap; an ordinary read or unrelated write must refuse it, never default.
250
+ if (caps.maxConcurrent !== undefined && !validCap(raw.maxConcurrent)) delete raw.maxConcurrent;
251
+ const reg = normalizeRegistry(raw);
243
252
  for (const [field, reset] of [["maxConcurrent", "default"], ["triggersMaxConcurrent", "none"]]) {
244
253
  if (caps[field] === reset) delete reg[field];
245
254
  else if (caps[field] !== undefined) reg[field] = caps[field];
246
255
  }
247
- if (!reg.workspaces.includes(abs)) reg.workspaces.push(abs);
248
- if (JSON.stringify(reg) !== before) writeRegistry(reg);
256
+ change(reg);
257
+ // Compare with the raw snapshot so even unchanged membership persists the
258
+ // migration, without a separate write or a stale pre-lock read.
259
+ if (JSON.stringify(reg) !== before) {
260
+ writeRegistry(reg);
261
+ if (legacyOne && (caps.maxConcurrent === undefined || caps.maxConcurrent === "default")) {
262
+ process.stderr.write("oats: warning: migrated legacy maxConcurrent: 1 to the default of 5; an old explicit 1 cannot be distinguished from the old implicit default. To restore one slot, run oats schedule host install --max-concurrent 1 --dir <deployment>. Stop older oats binaries from writing this shared registry.\n");
263
+ }
264
+ }
249
265
  return reg;
250
266
  });
251
267
  }
252
- export function unregisterWorkspace(ws) { return withRegistryLock(() => { const reg = readRegistryLocked(); reg.workspaces = reg.workspaces.filter((w) => w !== resolve(ws)); writeRegistry(reg); return reg; }); }
268
+ export function registerWorkspace(ws, caps = {}) {
269
+ for (const [field, reset] of [["maxConcurrent", "default"], ["triggersMaxConcurrent", "none"]]) {
270
+ const value = caps[field];
271
+ if (value !== undefined && value !== reset && !validCap(value)) {
272
+ throw scheduleError("E_BAD_ARGS", `${field} must be a positive safe integer or ${reset}`, { field });
273
+ }
274
+ }
275
+ return updateRegistry((reg) => { const abs = resolve(ws); if (!reg.workspaces.includes(abs)) reg.workspaces.push(abs); }, caps);
276
+ }
277
+ export function unregisterWorkspace(ws) { return updateRegistry((reg) => { reg.workspaces = reg.workspaces.filter((w) => w !== resolve(ws)); }); }
253
278
  export function readHostState() { return readJson(join(hostScheduleDir(), "state.json"), {}); }
254
279
  export function writeHostState(st) { writeJson(join(hostScheduleDir(), "state.json"), st); }
255
280
 
@@ -573,10 +598,12 @@ function spawnViaCli(ws, root, agent, opts, context, io) {
573
598
  let r;
574
599
  try { r = runScheduleCommand(process.execPath, [io?.oatsBin || OATS_BIN, ...argv], { cwd: context.dir, timeout: io?.commandTimeoutMs || COMMAND_TIMEOUT_MS, graceMs: io?.commandGraceMs, maxBuffer: 16 * 1024 * 1024, env: childEnv() }); }
575
600
  finally { try { rmSync(taskFile, { force: true }); } catch { /* best effort */ } }
576
- const timedOut = r.error?.code === "ETIMEDOUT" || (r.status === null && ["SIGTERM", "SIGKILL"].includes(r.signal));
601
+ const interrupted = r.interrupted === true;
602
+ const timedOut = r.error?.code === "ETIMEDOUT" || (!interrupted && r.status === null && ["SIGTERM", "SIGKILL"].includes(r.signal));
577
603
  const envelope = parseEnvelopeText(r.stdout);
578
604
  // The child may have created (and launched) a home before dying: unconfirmed, never a confirmed failure.
579
605
  // The thrown message reports RETAINED effects so the tick keeps the attempt and its slot for reconcile.
606
+ if (interrupted) throw scheduleError("E_SPAWN_UNCONFIRMED", "workspace spawn was interrupted; its effects could not be confirmed (a home may have been created) — run oats schedule reconcile");
580
607
  if (timedOut) throw scheduleError("E_SPAWN_UNCONFIRMED", "workspace spawn timed out; its effects could not be confirmed (a home may have been created) — run oats schedule reconcile");
581
608
  if (!envelope || typeof envelope !== "object" || typeof envelope.ok !== "boolean") throw scheduleError("E_SPAWN_UNCONFIRMED", `workspace spawn answered no valid envelope (exit ${r.status ?? r.signal}); its effects could not be confirmed — run oats schedule reconcile${r.stderr ? `: ${String(r.stderr).trim().split("\n").pop()}` : ""}`);
582
609
  if (!envelope.ok) {
@@ -634,19 +661,20 @@ export function parseEnvelopeText(text) {
634
661
  * and `io.commandGraceMs` are test seams; `io.command` counts as exited. */
635
662
  function launchCommand(ws, def, io) {
636
663
  const argv = def.argv.includes("--json") ? def.argv.slice(1) : [...def.argv.slice(1), "--json"];
637
- let envelope, timedOut = false, raw = "", exit;
664
+ let envelope, timedOut = false, interrupted = false, raw = "", exit;
638
665
  if (io?.command) { envelope = io.command({ cwd: def.cwd, argv }); exit = { status: null, signal: null }; }
639
666
  else {
640
667
  const r = runScheduleCommand(process.execPath, [io?.oatsBin || OATS_BIN, ...argv], { cwd: def.cwd, timeout: io?.commandTimeoutMs || COMMAND_TIMEOUT_MS, graceMs: io?.commandGraceMs, maxBuffer: 16 * 1024 * 1024, env: childEnv() });
641
- timedOut = r.error?.code === "ETIMEDOUT" || (r.status === null && ["SIGTERM", "SIGKILL"].includes(r.signal));
668
+ interrupted = r.interrupted === true;
669
+ timedOut = r.error?.code === "ETIMEDOUT" || (!interrupted && r.status === null && ["SIGTERM", "SIGKILL"].includes(r.signal));
642
670
  if (r.status !== null || r.signal !== null) exit = { status: r.status, signal: r.signal };
643
671
  raw = String(r.stdout || "");
644
672
  envelope = parseEnvelopeText(raw);
645
673
  }
646
674
  const exited = exit ? { exit } : {};
647
- // No envelope, a malformed one, or a timeout: the process may still have
675
+ // No envelope, a malformed one, a timeout, or supervisor interruption: the process may still have
648
676
  // created a home. That is unconfirmed, never a confirmed failure.
649
- if (timedOut || !envelope || typeof envelope !== "object" || typeof envelope.ok !== "boolean") return { kind: "command", launched: false, unconfirmed: true, ...exited, error: timedOut ? "command timed out; its side effects are unconfirmed" : "command answered no valid envelope; its side effects are unconfirmed" };
677
+ if (interrupted || timedOut || !envelope || typeof envelope !== "object" || typeof envelope.ok !== "boolean") return { kind: "command", launched: false, unconfirmed: true, ...exited, error: interrupted ? "command was interrupted; its side effects are unconfirmed" : timedOut ? "command timed out; its side effects are unconfirmed" : "command answered no valid envelope; its side effects are unconfirmed" };
650
678
  const res = envelope?.result || {};
651
679
  const instance = typeof res.instance === "string" ? res.instance : undefined;
652
680
  let home = typeof res.home === "string" ? res.home : undefined;
@@ -1091,6 +1119,7 @@ export function testSchedule(ws, qid, io, { now = new Date(), ctx = null } = {})
1091
1119
  if (def.harness) argv.push(`--harness=${def.harness}`);
1092
1120
  if (def.model) argv.push(`--model=${def.model}`);
1093
1121
  const r = runScheduleCommand(process.execPath, [io?.oatsBin || OATS_BIN, ...argv], { cwd: context.dir, timeout: io?.commandTimeoutMs || COMMAND_TIMEOUT_MS, graceMs: io?.commandGraceMs, maxBuffer: 16 * 1024 * 1024, env: childEnv() });
1122
+ if (r.interrupted === true) throw scheduleError("E_SPAWN_FAILED", "the spawn preview was interrupted; its result is unconfirmed");
1094
1123
  const envelope = parseEnvelopeText(r.stdout);
1095
1124
  if (!envelope || typeof envelope.ok !== "boolean") throw scheduleError("E_SPAWN_FAILED", `the spawn preview answered no valid envelope (exit ${r.status ?? r.signal})`);
1096
1125
  if (!envelope.ok) throw scheduleError(envelope.error?.code || "E_SPAWN_FAILED", String(envelope.error?.message || "spawn preview failed"));
package/lib/servers.mjs CHANGED
@@ -23,7 +23,9 @@ 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 { readWaitingClaim } from "./instance-events.mjs";
26
27
  import { withDirLock } from "./dir-lock.mjs";
28
+ import { shellWord } from "./core.mjs";
27
29
 
28
30
  const OATS_HOME_DIR = () => process.env.OATS_HOME_DIR || join(homedir(), ".oats");
29
31
  export const SERVERS_FILE = () => join(OATS_HOME_DIR(), "servers.json");
@@ -784,6 +786,11 @@ function listSnapshotServers() {
784
786
  * derived here. */
785
787
  export const REMOTE_ROW_FACTS = ["identity", "identityAddress", "teams", "startedAt", "createdAt", "model", "runtimeState", "parentInstance", "siblingInstance", "relation", "relativeTo", "spawnOrigin"];
786
788
  const rowFacts = (i = {}) => Object.fromEntries(REMOTE_ROW_FACTS.map((k) => [k, i[k] ?? null]));
789
+ /** `waitingOnYou` (feature waiting-on-you) is relayed ONLY when the host's row has the
790
+ * key, so it is not one of REMOTE_ROW_FACTS: a host whose kernel predates the feature
791
+ * omits it, and absent ("not reported") must stay distinct from null ("no claim"). The
792
+ * value passes the kernel's own read rule again; anything it refuses is null. */
793
+ const rowWaiting = (i) => (Object.hasOwn(i, "waitingOnYou") ? { waitingOnYou: readWaitingClaim(i.waitingOnYou) } : {});
787
794
 
788
795
  export function rosterGroups({ server, io = {} } = {}) {
789
796
  const started = Date.now();
@@ -841,7 +848,7 @@ export function rosterGroups({ server, io = {} } = {}) {
841
848
  harness: i.harness || i.runtime || null, backend: i.tmux ? "tmux" : null,
842
849
  ...(i.sessionTarget ? { sessionTarget: i.sessionTarget } : {}), ...(i.tmux ? { tmux: i.tmux } : {}),
843
850
  running: typeof i.running === "boolean" ? i.running : null, ...(i.runtimeError ? { runtimeError: i.runtimeError } : {}),
844
- ...rowFacts(i),
851
+ ...rowFacts(i), ...rowWaiting(i),
845
852
  // Every row the host reports is addressed by its home, saved
846
853
  // route or not; savedRoute says only that it was spawned from here.
847
854
  retirePending: !!i.retirePending, rollbackIncomplete: !!i.rollbackIncomplete, savedRoute: !!snap, addressable: true, missingRemotely: false,
@@ -911,22 +918,29 @@ export function instanceOnHost(serverId, name, io = {}) {
911
918
  function requireSessionRemote(target, io, home) {
912
919
  const remote = checkRemote(target, io);
913
920
  if (!remote.remote.includes("session")) {
914
- throw serverError("E_REMOTE_INCOMPATIBLE", `remote oats ${remote.version} at ${target.sshHost} does not advertise the \`oats session\` commands (kernels from ${SESSION_REMOTE_VERSION} do); upgrade it there, or attach with ssh -t ${target.sshHost} tmux attach -t ${recordedTmuxTarget(target, io, home)}`);
921
+ 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 ${oldKernelAttachHint(target, io, home)}`);
915
922
  }
916
923
  return remote;
917
924
  }
918
925
 
919
- /** Where a kernel without `oats session` put the instance's window: the tmux
920
- * session and window its roster records for that home, else its default
921
- * session (pi-agents, the default of every kernel before 0.31). */
926
+ /** How to attach by hand to an instance on a kernel without `oats session`: the tmux session and
927
+ * window its roster records for that home, else its default session (pi-agents, the default of every
928
+ * kernel before 0.31). When the roster row records the server's socket the hint names it, and the
929
+ * remote command is ONE quoted word (ssh joins its words for the remote shell, which would split a
930
+ * path with a space); a row without a socket is a kernel that only ever used its default server. */
922
931
  const OLD_KERNEL_TMUX_SESSION = "pi-agents";
923
- function recordedTmuxTarget(target, io, home) {
932
+ function oldKernelAttachHint(target, io, home) {
933
+ let row;
924
934
  try {
925
935
  const status = runRemote(target, ["status", "--json", "--dir", target.workspace], { ...io, input: undefined }).envelope;
926
- const row = (status.ok ? status.result?.agents || [] : []).flatMap((a) => a.instances || []).find((i) => i.home && home && resolve(i.home) === resolve(home));
927
- if (row?.tmux?.session) return row.tmux.window ? `${row.tmux.session}:${row.tmux.window}` : row.tmux.session;
936
+ row = (status.ok ? status.result?.agents || [] : []).flatMap((a) => a.instances || []).find((i) => i.home && home && resolve(i.home) === resolve(home));
928
937
  } catch { /* the hint falls back to the default session */ }
929
- return OLD_KERNEL_TMUX_SESSION;
938
+ const tmux = row?.tmux?.session ? row.tmux : null;
939
+ const recorded = tmux ? (tmux.window ? `${tmux.session}:${tmux.window}` : tmux.session) : OLD_KERNEL_TMUX_SESSION;
940
+ // The remote command is ONE word for the local shell, with or without a socket: ssh joins its
941
+ // arguments and the remote shell reads the result again.
942
+ const server = typeof tmux?.socket === "string" && tmux.socket ? `-S ${shellWord(tmux.socket)} ` : "";
943
+ return `ssh -t ${target.sshHost} ${shellWord(`tmux ${server}attach -t ${shellWord(recorded)}`)}`;
930
944
  }
931
945
 
932
946
  export function inspectRemote(serverId, { instance, home } = {}, io = {}) {
@@ -2,13 +2,14 @@
2
2
  import { execFileSync } from "node:child_process";
3
3
  import { basename } from "node:path";
4
4
  import { randomUUID } from "node:crypto";
5
+ import { performance } from "node:perf_hooks";
5
6
 
6
7
  const shells = new Set(["sh", "bash", "zsh", "fish", "dash", "ksh", "login"]);
7
- function tmux(target, args, { exec = execFileSync, input } = {}) {
8
+ function tmux(target, args, { exec = execFileSync, input, timeout = 10000, killSignal } = {}) {
8
9
  // launchd does not supply a UTF-8 locale. In that environment tmux replaces
9
10
  // the tab delimiters in list-panes output with underscores unless forced.
10
11
  return exec("tmux", ["-u", "-S", target.socket, ...args], {
11
- encoding: "utf8", input, timeout: 10000, maxBuffer: 1024 * 1024,
12
+ encoding: "utf8", input, timeout, ...(killSignal ? { killSignal } : {}), maxBuffer: 1024 * 1024,
12
13
  stdio: ["pipe", "pipe", "pipe"],
13
14
  });
14
15
  }
@@ -49,13 +50,11 @@ export function inspectSessionTarget(target, io) {
49
50
  }
50
51
  return { backend: "tmux", present: dead === "0", state, paneId };
51
52
  }
52
- // Submit verification (#562). A harness can swallow an Enter that lands while
53
- // it is still handling a large bracketed paste, leaving the text unsent in its
54
- // input box. So the pane is left to settle before Enter, and Enter is judged by
55
- // whether it changed the bottom of the screen: a byte comparison only, never a
56
- // reading of any harness's UI, and the pane's text is never acted on.
53
+ // Settling gives a large bracketed paste time before the single Enter. Screen
54
+ // bytes are observations only: neither change nor silence proves acceptance
55
+ // or authorizes another key. Each phase has one monotonic observation budget.
57
56
  const SETTLE_FLOOR_MS = 200, SETTLE_MS_PER_KIB = 3, SETTLE_CAP_MS = 2000, SETTLE_POLL_MS = 100;
58
- const VERIFY_MS = 300, RESEND_BACKOFF_MS = [300, 600]; // at most 3 Enters in total
57
+ const OBSERVE_CAP_MS = 1000, OBSERVE_WAITS_MS = [300, 600];
59
58
  const REGION_LINES = 15, HISTORY_LINES = 200;
60
59
  const GEOMETRY = "#{pane_width}x#{pane_height}";
61
60
  const realSleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
@@ -65,17 +64,22 @@ const realSleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)),
65
64
  * visible pane with up to 200 lines of history above it; and `size`, the
66
65
  * pane's geometry, read before and after the capture in the same tmux call
67
66
  * (null when it moved in between). */
68
- function capturePane(target, paneId, io) {
67
+ function capturePane(target, paneId, io, deadline, now) {
68
+ const timeout = Math.floor(deadline - now());
69
+ if (timeout <= 0) return null;
69
70
  try {
70
71
  const lines = tmux(target, ["display-message", "-p", "-t", paneId, GEOMETRY, ";",
71
72
  "capture-pane", "-p", "-J", "-t", paneId, "-S", `-${HISTORY_LINES}`, ";",
72
- "display-message", "-p", "-t", paneId, GEOMETRY], io).split("\n");
73
+ // A read-only probe must not outlive its timeout by ignoring TERM.
74
+ "display-message", "-p", "-t", paneId, GEOMETRY], { ...io, timeout, killSignal: "SIGKILL" }).split("\n");
75
+ if (now() >= deadline) return null;
73
76
  if (lines.at(-1) === "") lines.pop();
74
77
  const first = lines.shift(), last = lines.pop();
75
78
  if (!/^\d+x\d+$/.test(first) || !/^\d+x\d+$/.test(last)) return null;
76
79
  for (let i = 0; i < lines.length; i++) lines[i] = lines[i].trimEnd();
77
80
  while (lines.length && !lines.at(-1)) lines.pop();
78
- return { size: first === last ? first : null, region: lines.slice(-REGION_LINES).join("\n"), all: lines.join("\n") };
81
+ const look = { size: first === last ? first : null, region: lines.slice(-REGION_LINES).join("\n"), all: lines.join("\n") };
82
+ return now() < deadline ? look : null;
79
83
  } catch { return null; }
80
84
  }
81
85
  const sameLook = (a, b) => a.size === b.size && a.region === b.region;
@@ -95,58 +99,49 @@ function unchanged(a, b) {
95
99
  if (!x || !y) return x === y;
96
100
  return (x.endsWith(y) || y.endsWith(x)) && flat(b.all).includes(x) && flat(a.all).includes(y);
97
101
  }
98
- /** Paste `text` and submit it with Enter. Answers `submitted` (the Enter was
99
- * taken, or sent unjudged) and `verified` (whether the screen comparison
100
- * judged it); `reason: "enter-not-taken"` only with `submitted: false`. A
101
- * failed paste or key send throws; a failed capture never does. */
102
+ /** One literal paste and one Enter. `submitted` means terminal commands
103
+ * succeeded; `verified` means only that a bounded look changed afterward.
104
+ * Neither is model acceptance. Terminal failures throw; observation failures
105
+ * yield unverified, never a refusal or permission to retransmit. */
102
106
  export function inputSessionTarget(target, text, io) {
103
107
  const state = inspectSessionTarget(target, io);
104
108
  if (!state.present || state.state === "shell") throw new Error(`cannot submit input: session is ${state.state}`);
105
- const sleep = io?.sleep || realSleep, now = io?.now || Date.now;
109
+ const sleep = io?.sleep || realSleep, now = io?.now || (() => performance.now());
110
+ const wait = (ms, deadline) => { const remaining = deadline - now(); if (remaining > 0) sleep(Math.min(ms, remaining)); };
106
111
  // Bracketed paste preserves multiline input as one user message. No text
107
112
  // is evaluated by a shell or interpreted as tmux key names.
108
113
  const buffer = `oats-${randomUUID()}`;
114
+ let settleDeadline;
109
115
  try {
110
116
  tmux(target, ["load-buffer", "-b", buffer, "-"], { ...io, input: text });
111
117
  tmux(target, ["paste-buffer", "-p", "-b", buffer, "-t", state.paneId], io);
118
+ settleDeadline = now() + SETTLE_CAP_MS;
112
119
  } finally {
113
120
  try { tmux(target, ["delete-buffer", "-b", buffer], io); } catch { /* already consumed or disconnected */ }
114
121
  }
115
- const enter = () => tmux(target, ["send-keys", "-t", state.paneId, "Enter"], io);
116
- // Settle: wait a floor scaled by paste size, then until two consecutive
117
- // captures are identical, giving up at the cap. The last capture is A.
118
- const started = now();
119
- sleep(Math.min(SETTLE_CAP_MS, SETTLE_FLOOR_MS + Math.ceil(Buffer.byteLength(text) / 1024) * SETTLE_MS_PER_KIB));
120
- let before = capturePane(target, state.paneId, io);
121
- while (before !== null && now() - started < SETTLE_CAP_MS) {
122
- sleep(SETTLE_POLL_MS);
123
- const next = capturePane(target, state.paneId, io);
122
+ // The budget starts at paste completion, before cleanup. Cleanup/Enter keep
123
+ // their original command timeouts; only observation probes/sleeps are clipped.
124
+ wait(SETTLE_FLOOR_MS + Math.ceil(Buffer.byteLength(text) / 1024) * SETTLE_MS_PER_KIB, settleDeadline);
125
+ let before = capturePane(target, state.paneId, io, settleDeadline, now);
126
+ while (before !== null && now() < settleDeadline) {
127
+ wait(SETTLE_POLL_MS, settleDeadline);
128
+ const next = capturePane(target, state.paneId, io, settleDeadline, now);
124
129
  const settled = next !== null && sameLook(next, before);
125
130
  before = next;
126
131
  if (settled) break;
127
132
  }
128
- enter();
129
- // An unreadable pane gets no further keys: the terminal accepted them, unjudged.
130
- if (before === null) return { ...state, submitted: true, verified: false };
131
- // Changed content means the Enter was taken (or the pane moved for an
132
- // unrelated reason, such as a spinner, a clock or a human typing: read as
133
- // taken, no worse than not looking). Unchanged content means it was
134
- // swallowed: any reaction, a dialog included, would have changed it, so a
135
- // resend is safe. A reflow alone, on a pane seen to change size, is not a
136
- // change. The screen is looked at again after each backoff, so an Enter
137
- // taken late is not followed by another. A capture that fails stops the
138
- // retries: no further Enter is sent.
139
- for (let enters = 1; ; enters++) {
140
- for (const wait of [VERIFY_MS, RESEND_BACKOFF_MS[enters - 1]]) {
141
- if (wait === undefined) break;
142
- sleep(wait);
143
- const after = capturePane(target, state.paneId, io);
144
- if (after === null) return { ...state, submitted: true, verified: false };
145
- if (!unchanged(before, after)) return { ...state, submitted: true, verified: true };
133
+ if (now() >= settleDeadline) before = null;
134
+ tmux(target, ["send-keys", "-t", state.paneId, "Enter"], io);
135
+ const observeDeadline = now() + OBSERVE_CAP_MS;
136
+ if (before !== null) {
137
+ for (const delay of OBSERVE_WAITS_MS) {
138
+ wait(delay, observeDeadline);
139
+ const after = capturePane(target, state.paneId, io, observeDeadline, now);
140
+ if (after === null) break;
141
+ const changed = !unchanged(before, after);
142
+ if (now() >= observeDeadline) break;
143
+ if (changed) return { ...state, submitted: true, verified: true };
146
144
  }
147
- if (enters > RESEND_BACKOFF_MS.length) break;
148
- enter();
149
145
  }
150
- // The text stays in the input box; it is never pasted again.
151
- return { ...state, submitted: false, verified: true, reason: "enter-not-taken" };
146
+ return { ...state, submitted: true, verified: false };
152
147
  }
@@ -38,7 +38,7 @@
38
38
  },
39
39
  "oats.framework": {
40
40
  "url": "https://github.com/awebai/oats.git",
41
- "ref": "oats-framework/v1.6.0",
41
+ "ref": "oats-framework/v1.6.1",
42
42
  "path": "oats-package"
43
43
  }
44
44
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awebai/oats",
3
- "version": "0.40.1",
3
+ "version": "0.41.0",
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",
@@ -60,7 +60,7 @@ members:
60
60
  - git:github.com/acme/agents # the host is a member too
61
61
  - git:github.com/acme/platform
62
62
  packages:
63
- oats.framework: v1.6.0 # bare versions resolve through the official catalog
63
+ oats.framework: v1.6.1 # bare versions resolve through the official catalog
64
64
  oats.okf: v4.1.1
65
65
  defaults:
66
66
  capabilities: { oats.core: { from: package } }