@awebai/oats 0.39.3 → 0.39.4
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/docs/execution-targets.md +26 -3
- package/docs/release-notes/v0.39.4.md +22 -0
- package/lib/session-input.mjs +88 -2
- package/package.json +1 -1
|
@@ -142,9 +142,32 @@ oats session attach --home /abs/home
|
|
|
142
142
|
- **input** submits UTF-8 text (stdin or `--text-file`, at most 256 KiB, no
|
|
143
143
|
NUL) followed by Enter, as a bracketed paste. The text is never run by a
|
|
144
144
|
shell. A fallback shell, a stopped session or a split
|
|
145
|
-
tmux window is refused.
|
|
146
|
-
|
|
147
|
-
|
|
145
|
+
tmux window is refused. The text is pasted once. Enter waits for the pane to
|
|
146
|
+
settle (two identical captures, at least about 200 ms, longer for a larger
|
|
147
|
+
paste, at most 2 s), and is judged by whether it changed the bottom 15 lines
|
|
148
|
+
of the pane. The comparison is of bytes only, and the pane's text is never
|
|
149
|
+
interpreted. Trailing spaces are ignored. When the pane was seen to change
|
|
150
|
+
size (a resize, or a second client attaching), a reflow of the same content
|
|
151
|
+
is not a change either: each side's content must already be on the other
|
|
152
|
+
side's screen or in its history, so content that appeared or disappeared
|
|
153
|
+
still counts. An Enter that changed nothing was swallowed, and is resent
|
|
154
|
+
after a backoff, at most 3 Enters in total. The answer adds:
|
|
155
|
+
- `submitted: true, verified: true`: an Enter was taken.
|
|
156
|
+
- `submitted: false, verified: true, reason: "enter-not-taken"`: none of the
|
|
157
|
+
3 Enters changed the pane. The text stays in the agent's input box; it is
|
|
158
|
+
not pasted again.
|
|
159
|
+
- `submitted: true, verified: false`: a capture failed, so no further Enter
|
|
160
|
+
was sent and the last one was not judged. As before, this means the
|
|
161
|
+
terminal accepted the keys. When the pane cannot be read before the first
|
|
162
|
+
resend, exactly one Enter was sent.
|
|
163
|
+
|
|
164
|
+
`submitted` never means the agent processed the text. A pane that changes
|
|
165
|
+
for another reason after Enter (a spinner, a clock, a human typing) reads as
|
|
166
|
+
taken. A harness that shows no visible reaction to Enter receives up to two
|
|
167
|
+
extra Enters; real harnesses (claude, codex, pi) redraw on submit. A call takes at most about 4 s plus its tmux calls. A failed paste or
|
|
168
|
+
key send is `E_SESSION_INPUT_FAILED`, with no retry. Wake schedules and
|
|
169
|
+
messaging capabilities use this command ([schedules.md](schedules.md)); a
|
|
170
|
+
wake schedule records any answer as delivered.
|
|
148
171
|
- **attach** is interactive and takes no `--json`. It opens a temporary tmux
|
|
149
172
|
session linked to the agent's window alone.
|
|
150
173
|
Closing the viewer leaves the agent running.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# OATS 0.39.4
|
|
2
|
+
|
|
3
|
+
## Fixed
|
|
4
|
+
|
|
5
|
+
- **`oats session input` no longer reports an unsent message as submitted.**
|
|
6
|
+
After a large bracketed paste, Claude Code could swallow the Enter that
|
|
7
|
+
followed it at once, leaving the text in the agent's input box until someone
|
|
8
|
+
pressed Enter, while the command answered `submitted: true` (#562). Enter
|
|
9
|
+
now waits for the pane to settle, and is judged by whether it changed the
|
|
10
|
+
bottom of the pane. This is a byte comparison that ignores trailing spaces,
|
|
11
|
+
and also ignores a reflow when the pane was seen to change size, as when
|
|
12
|
+
OATS Desktop attaches. The pane's text is never interpreted. A swallowed
|
|
13
|
+
Enter is resent with a backoff, at most 3 in total, and the text is never
|
|
14
|
+
pasted again. The answer gains `verified`: `true` when the comparison
|
|
15
|
+
judged the Enter, `false` when a capture failed (then no further Enter is
|
|
16
|
+
sent, and `submitted: true` keeps its old meaning). When no Enter is taken
|
|
17
|
+
the answer is `submitted: false, verified: true, reason: "enter-not-taken"`;
|
|
18
|
+
a consumer that requires `submitted: true`, such as the aw wake broker, then
|
|
19
|
+
reports a failed delivery instead of a silent one. A harness that shows no
|
|
20
|
+
visible reaction to Enter receives up to two extra Enters; real harnesses
|
|
21
|
+
(claude, codex, pi) redraw on submit. A call now takes up to about 4 s. See
|
|
22
|
+
[execution targets](../execution-targets.md).
|
package/lib/session-input.mjs
CHANGED
|
@@ -49,18 +49,104 @@ export function inspectSessionTarget(target, io) {
|
|
|
49
49
|
}
|
|
50
50
|
return { backend: "tmux", present: dead === "0", state, paneId };
|
|
51
51
|
}
|
|
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.
|
|
57
|
+
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
|
|
59
|
+
const REGION_LINES = 15, HISTORY_LINES = 200;
|
|
60
|
+
const GEOMETRY = "#{pane_width}x#{pane_height}";
|
|
61
|
+
const realSleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
62
|
+
/** One look at the pane, or null when it cannot be read: `region`, the bottom
|
|
63
|
+
* 15 lines of the visible pane (trailing blank rows dropped, and each line's
|
|
64
|
+
* trailing spaces, which a redraw pads differently); `all`, the
|
|
65
|
+
* visible pane with up to 200 lines of history above it; and `size`, the
|
|
66
|
+
* pane's geometry, read before and after the capture in the same tmux call
|
|
67
|
+
* (null when it moved in between). */
|
|
68
|
+
function capturePane(target, paneId, io) {
|
|
69
|
+
try {
|
|
70
|
+
const lines = tmux(target, ["display-message", "-p", "-t", paneId, GEOMETRY, ";",
|
|
71
|
+
"capture-pane", "-p", "-J", "-t", paneId, "-S", `-${HISTORY_LINES}`, ";",
|
|
72
|
+
"display-message", "-p", "-t", paneId, GEOMETRY], io).split("\n");
|
|
73
|
+
if (lines.at(-1) === "") lines.pop();
|
|
74
|
+
const first = lines.shift(), last = lines.pop();
|
|
75
|
+
if (!/^\d+x\d+$/.test(first) || !/^\d+x\d+$/.test(last)) return null;
|
|
76
|
+
for (let i = 0; i < lines.length; i++) lines[i] = lines[i].trimEnd();
|
|
77
|
+
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") };
|
|
79
|
+
} catch { return null; }
|
|
80
|
+
}
|
|
81
|
+
const sameLook = (a, b) => a.size === b.size && a.region === b.region;
|
|
82
|
+
/** Width-independent text: each run of one box-drawing character kept as one,
|
|
83
|
+
* so a rule of any length reads the same, then whitespace dropped. Runs are
|
|
84
|
+
* collapsed first, so a glyph is never merged with a rule on the next line. */
|
|
85
|
+
const flat = (text) => text.replace(/([\u2500-\u257f])\1+/g, "$1").replace(/\s+/g, "");
|
|
86
|
+
/** Whether the pane after Enter shows what it showed before. On a pane of the
|
|
87
|
+
* same size this is exact bytes. Only when its size was seen to change (a
|
|
88
|
+
* resize, a second client attaching) is a reflow allowed for: then the two
|
|
89
|
+
* bottom regions must agree at the bottom, and each must already be present,
|
|
90
|
+
* width aside, in the other's capture with history, so content that appeared
|
|
91
|
+
* or disappeared still reads as a change. */
|
|
92
|
+
function unchanged(a, b) {
|
|
93
|
+
if (a.size !== null && a.size === b.size) return a.region === b.region;
|
|
94
|
+
const x = flat(a.region), y = flat(b.region);
|
|
95
|
+
if (!x || !y) return x === y;
|
|
96
|
+
return (x.endsWith(y) || y.endsWith(x)) && flat(b.all).includes(x) && flat(a.all).includes(y);
|
|
97
|
+
}
|
|
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. */
|
|
52
102
|
export function inputSessionTarget(target, text, io) {
|
|
53
103
|
const state = inspectSessionTarget(target, io);
|
|
54
104
|
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;
|
|
55
106
|
// Bracketed paste preserves multiline input as one user message. No text
|
|
56
107
|
// is evaluated by a shell or interpreted as tmux key names.
|
|
57
108
|
const buffer = `oats-${randomUUID()}`;
|
|
58
109
|
try {
|
|
59
110
|
tmux(target, ["load-buffer", "-b", buffer, "-"], { ...io, input: text });
|
|
60
111
|
tmux(target, ["paste-buffer", "-p", "-b", buffer, "-t", state.paneId], io);
|
|
61
|
-
tmux(target, ["send-keys", "-t", state.paneId, "Enter"], io);
|
|
62
112
|
} finally {
|
|
63
113
|
try { tmux(target, ["delete-buffer", "-b", buffer], io); } catch { /* already consumed or disconnected */ }
|
|
64
114
|
}
|
|
65
|
-
|
|
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);
|
|
124
|
+
const settled = next !== null && sameLook(next, before);
|
|
125
|
+
before = next;
|
|
126
|
+
if (settled) break;
|
|
127
|
+
}
|
|
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 };
|
|
146
|
+
}
|
|
147
|
+
if (enters > RESEND_BACKOFF_MS.length) break;
|
|
148
|
+
enter();
|
|
149
|
+
}
|
|
150
|
+
// The text stays in the input box; it is never pasted again.
|
|
151
|
+
return { ...state, submitted: false, verified: true, reason: "enter-not-taken" };
|
|
66
152
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@awebai/oats",
|
|
3
|
-
"version": "0.39.
|
|
3
|
+
"version": "0.39.4",
|
|
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",
|