@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.
@@ -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. `submitted: true` means the terminal accepted the
146
- text, not that the agent processed it. Wake schedules and messaging
147
- capabilities use this command ([schedules.md](schedules.md)).
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).
@@ -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
- return { ...state, submitted: true };
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",
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",