@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/bin/oats.mjs +45 -18
- package/docs/capabilities.md +44 -3
- package/docs/desktop-cli-api.md +147 -23
- package/docs/desktop.md +5 -4
- package/docs/execution-targets.md +342 -38
- package/docs/implementation.md +85 -11
- 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/release-notes/v0.41.0.md +238 -0
- package/docs/schedules.md +40 -11
- package/docs/servers.md +7 -2
- package/docs/souls-and-instances.md +32 -6
- package/docs/workspaces.md +1 -1
- package/lib/core.mjs +579 -177
- package/lib/dir-lock.mjs +7 -4
- package/lib/instance-events.mjs +130 -46
- package/lib/instance-git.mjs +113 -4
- package/lib/instance-lifecycle.mjs +3 -2
- package/lib/packages.mjs +1 -1
- package/lib/resolve.mjs +1 -1
- package/lib/schedule-command-child.mjs +39 -9
- package/lib/schedule.mjs +57 -28
- package/lib/servers.mjs +23 -9
- 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,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
|
|
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
|
-
/**
|
|
920
|
-
*
|
|
921
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 = {}) {
|
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.
|
|
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.
|
|
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 } }
|