@awebai/oats 0.40.1 → 0.40.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/bin/oats.mjs +6 -6
- package/docs/capabilities.md +35 -2
- package/docs/desktop-cli-api.md +89 -7
- package/docs/execution-targets.md +30 -29
- package/docs/implementation.md +48 -5
- package/docs/oats-local.schema.json +2 -2
- package/docs/official-catalog.md +1 -1
- package/docs/packages.md +5 -5
- package/docs/release-lane.md +8 -4
- package/docs/release-notes/v0.40.2.md +120 -0
- package/docs/schedules.md +40 -11
- package/docs/servers.md +3 -1
- package/docs/workspaces.md +1 -1
- package/lib/core.mjs +27 -12
- package/lib/dir-lock.mjs +7 -4
- package/lib/instance-events.mjs +130 -46
- package/lib/schedule-command-child.mjs +39 -9
- package/lib/schedule.mjs +57 -28
- package/lib/servers.mjs +7 -1
- package/lib/session-input.mjs +42 -47
- package/package-catalog.json +1 -1
- package/package.json +1 -1
- package/skills/oats-getting-started/SKILL.md +1 -1
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
|
-
|
|
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
|
-
/**
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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 &&
|
|
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 && !(
|
|
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
|
|
232
|
-
/** Raw write:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
248
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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,6 +23,7 @@ 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";
|
|
27
28
|
|
|
28
29
|
const OATS_HOME_DIR = () => process.env.OATS_HOME_DIR || join(homedir(), ".oats");
|
|
@@ -784,6 +785,11 @@ function listSnapshotServers() {
|
|
|
784
785
|
* derived here. */
|
|
785
786
|
export const REMOTE_ROW_FACTS = ["identity", "identityAddress", "teams", "startedAt", "createdAt", "model", "runtimeState", "parentInstance", "siblingInstance", "relation", "relativeTo", "spawnOrigin"];
|
|
786
787
|
const rowFacts = (i = {}) => Object.fromEntries(REMOTE_ROW_FACTS.map((k) => [k, i[k] ?? null]));
|
|
788
|
+
/** `waitingOnYou` (feature waiting-on-you) is relayed ONLY when the host's row has the
|
|
789
|
+
* key, so it is not one of REMOTE_ROW_FACTS: a host whose kernel predates the feature
|
|
790
|
+
* omits it, and absent ("not reported") must stay distinct from null ("no claim"). The
|
|
791
|
+
* value passes the kernel's own read rule again; anything it refuses is null. */
|
|
792
|
+
const rowWaiting = (i) => (Object.hasOwn(i, "waitingOnYou") ? { waitingOnYou: readWaitingClaim(i.waitingOnYou) } : {});
|
|
787
793
|
|
|
788
794
|
export function rosterGroups({ server, io = {} } = {}) {
|
|
789
795
|
const started = Date.now();
|
|
@@ -841,7 +847,7 @@ export function rosterGroups({ server, io = {} } = {}) {
|
|
|
841
847
|
harness: i.harness || i.runtime || null, backend: i.tmux ? "tmux" : null,
|
|
842
848
|
...(i.sessionTarget ? { sessionTarget: i.sessionTarget } : {}), ...(i.tmux ? { tmux: i.tmux } : {}),
|
|
843
849
|
running: typeof i.running === "boolean" ? i.running : null, ...(i.runtimeError ? { runtimeError: i.runtimeError } : {}),
|
|
844
|
-
...rowFacts(i),
|
|
850
|
+
...rowFacts(i), ...rowWaiting(i),
|
|
845
851
|
// Every row the host reports is addressed by its home, saved
|
|
846
852
|
// route or not; savedRoute says only that it was spawned from here.
|
|
847
853
|
retirePending: !!i.retirePending, rollbackIncomplete: !!i.rollbackIncomplete, savedRoute: !!snap, addressable: true, missingRemotely: false,
|
package/lib/session-input.mjs
CHANGED
|
@@ -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:
|
|
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
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
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 ||
|
|
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
|
-
|
|
116
|
-
//
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
151
|
-
return { ...state, submitted: false, verified: true, reason: "enter-not-taken" };
|
|
146
|
+
return { ...state, submitted: true, verified: false };
|
|
152
147
|
}
|
package/package-catalog.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@awebai/oats",
|
|
3
|
-
"version": "0.40.
|
|
3
|
+
"version": "0.40.2",
|
|
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.
|
|
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 } }
|