mandala-computer-mcp 0.1.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/LICENSE +21 -0
- package/README.md +544 -0
- package/dist/api.d.ts +186 -0
- package/dist/api.d.ts.map +1 -0
- package/dist/api.js +932 -0
- package/dist/api.js.map +1 -0
- package/dist/cli.d.ts +55 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +292 -0
- package/dist/cli.js.map +1 -0
- package/dist/errors.d.ts +560 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +873 -0
- package/dist/errors.js.map +1 -0
- package/dist/events.d.ts +406 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +1679 -0
- package/dist/events.js.map +1 -0
- package/dist/format.d.ts +125 -0
- package/dist/format.d.ts.map +1 -0
- package/dist/format.js +180 -0
- package/dist/format.js.map +1 -0
- package/dist/http.d.ts +46 -0
- package/dist/http.d.ts.map +1 -0
- package/dist/http.js +792 -0
- package/dist/http.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/paths.d.ts +394 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +677 -0
- package/dist/paths.js.map +1 -0
- package/dist/server.d.ts +18 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +97 -0
- package/dist/server.js.map +1 -0
- package/dist/session.d.ts +78 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +166 -0
- package/dist/session.js.map +1 -0
- package/dist/stdio.d.ts +11 -0
- package/dist/stdio.d.ts.map +1 -0
- package/dist/stdio.js +43 -0
- package/dist/stdio.js.map +1 -0
- package/dist/tools/agent.d.ts +16 -0
- package/dist/tools/agent.d.ts.map +1 -0
- package/dist/tools/agent.js +147 -0
- package/dist/tools/agent.js.map +1 -0
- package/dist/tools/computers.d.ts +3 -0
- package/dist/tools/computers.d.ts.map +1 -0
- package/dist/tools/computers.js +1037 -0
- package/dist/tools/computers.js.map +1 -0
- package/dist/tools/events.d.ts +3 -0
- package/dist/tools/events.d.ts.map +1 -0
- package/dist/tools/events.js +1077 -0
- package/dist/tools/events.js.map +1 -0
- package/dist/tools/guest.d.ts +3 -0
- package/dist/tools/guest.d.ts.map +1 -0
- package/dist/tools/guest.js +761 -0
- package/dist/tools/guest.js.map +1 -0
- package/dist/tools/input.d.ts +3 -0
- package/dist/tools/input.d.ts.map +1 -0
- package/dist/tools/input.js +240 -0
- package/dist/tools/input.js.map +1 -0
- package/dist/tools/snapshots.d.ts +3 -0
- package/dist/tools/snapshots.d.ts.map +1 -0
- package/dist/tools/snapshots.js +333 -0
- package/dist/tools/snapshots.js.map +1 -0
- package/dist/tools/templates.d.ts +3 -0
- package/dist/tools/templates.d.ts.map +1 -0
- package/dist/tools/templates.js +492 -0
- package/dist/tools/templates.js.map +1 -0
- package/dist/tools/types.d.ts +18 -0
- package/dist/tools/types.d.ts.map +1 -0
- package/dist/tools/types.js +2 -0
- package/dist/tools/types.js.map +1 -0
- package/dist/tools/webhooks.d.ts +3 -0
- package/dist/tools/webhooks.d.ts.map +1 -0
- package/dist/tools/webhooks.js +260 -0
- package/dist/tools/webhooks.js.map +1 -0
- package/package.json +59 -0
|
@@ -0,0 +1,1077 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { cleanWatchPath, MAX_WATCHES, } from '../events.js';
|
|
3
|
+
import { guarded, refused, said, unwrapComputer, withoutCredentials, } from '../format.js';
|
|
4
|
+
import * as P from '../paths.js';
|
|
5
|
+
const idArg = {
|
|
6
|
+
computer_id: z
|
|
7
|
+
.string()
|
|
8
|
+
.optional()
|
|
9
|
+
.describe('Which computer. Defaults to the one selected with use_computer.'),
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* The types this build knows the meaning of, for the tool descriptions only.
|
|
13
|
+
*
|
|
14
|
+
* Not an enum on the argument, and that is deliberate: the reference says in as
|
|
15
|
+
* many words that the vocabulary grows and that a client must ignore a `type`
|
|
16
|
+
* it does not recognise. A closed enum here would refuse a model waiting for an
|
|
17
|
+
* event the platform had started sending, which is the wrong way round for a
|
|
18
|
+
* list that is documented as open.
|
|
19
|
+
*/
|
|
20
|
+
const KNOWN_TYPES = [
|
|
21
|
+
'window.opened',
|
|
22
|
+
'window.closed',
|
|
23
|
+
'window.focused',
|
|
24
|
+
'window.blurred',
|
|
25
|
+
'clipboard.changed',
|
|
26
|
+
// The one type nobody is sent unasked (platform OPL-3927). It is here so a
|
|
27
|
+
// model reading this list knows it exists; a wait that names it and nothing
|
|
28
|
+
// else, on a stream watching nothing, is answered with the sentence that says
|
|
29
|
+
// how to make one arrive rather than with a timeout.
|
|
30
|
+
'file.changed',
|
|
31
|
+
'process.exited',
|
|
32
|
+
'computer.ready',
|
|
33
|
+
'computer.idle',
|
|
34
|
+
'computer.started',
|
|
35
|
+
'computer.stopped',
|
|
36
|
+
'computer.suspended',
|
|
37
|
+
];
|
|
38
|
+
/**
|
|
39
|
+
* The longest wait this server will hold a tool call open for.
|
|
40
|
+
*
|
|
41
|
+
* Measured rather than assumed, which is what OPL-3926 asked for. The MCP SDK
|
|
42
|
+
* on both ends of this — `@modelcontextprotocol/sdk`, in this package's own
|
|
43
|
+
* `node_modules` — starts a 60-second timer per request in
|
|
44
|
+
* `DEFAULT_REQUEST_TIMEOUT_MSEC`, and most clients ship that default unchanged.
|
|
45
|
+
* A tool that blocks past it does not return late; it is cancelled, and the
|
|
46
|
+
* model is told the server failed.
|
|
47
|
+
*
|
|
48
|
+
* So the cap sits under it with room for the round trip. `wait_for_computer`
|
|
49
|
+
* next door goes to 900 seconds and is right to: what it is waiting for cannot
|
|
50
|
+
* be missed by not watching, so a client that gives up at 60 costs one wasted
|
|
51
|
+
* call and nothing else. Here the whole point is that nothing is missed between
|
|
52
|
+
* calls — the socket stays open and the buffer keeps filling whether or not
|
|
53
|
+
* anybody is in a tool call — so a short cap costs precisely nothing. Waiting
|
|
54
|
+
* again is free, and it is the documented answer to a timeout.
|
|
55
|
+
*/
|
|
56
|
+
const MAX_WAIT_S = 55;
|
|
57
|
+
/** How long a first call gives the socket to reach its opening frame. */
|
|
58
|
+
const ATTACH_MS = 20_000;
|
|
59
|
+
/**
|
|
60
|
+
* Wait for a subscription to say something about itself, inside a budget.
|
|
61
|
+
*
|
|
62
|
+
* The first call on a computer has to attach before it can answer anything: a
|
|
63
|
+
* poll that returned "no events" while the socket was still being opened would
|
|
64
|
+
* be a model told nothing had happened, which is a different sentence from
|
|
65
|
+
* "nothing has happened yet" and the one that ends a turn early.
|
|
66
|
+
*
|
|
67
|
+
* `deadline` is the CALLER's, and passing it is what keeps `ATTACH_MS` from
|
|
68
|
+
* being a second budget stacked in front of the first. Without it a
|
|
69
|
+
* `wait_for_event` spent up to twenty seconds here and only then armed its own
|
|
70
|
+
* `timeout_s`, so a call promising to come back in one second came back in
|
|
71
|
+
* twenty — and one asking for the maximum ran past the sixty most MCP clients
|
|
72
|
+
* allow a request, which is the cancellation {@link MAX_WAIT_S} exists to
|
|
73
|
+
* prevent. Whichever fires first wins; `poll_events` has no deadline of its own
|
|
74
|
+
* and keeps the handshake budget alone.
|
|
75
|
+
*/
|
|
76
|
+
async function attached(sub, cancel, deadline) {
|
|
77
|
+
if (sub.eventTypes || sub.state.status === 'stopped')
|
|
78
|
+
return;
|
|
79
|
+
const budget = AbortSignal.timeout(ATTACH_MS);
|
|
80
|
+
await sub.attached(deadline ? AbortSignal.any([budget, deadline]) : budget, cancel);
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* The state a model would otherwise have to go and fetch, fetched here.
|
|
84
|
+
*
|
|
85
|
+
* This is the answer to the third question on OPL-3926 — what a `gap` means to
|
|
86
|
+
* a caller that cannot be told to "reconcile with a listing". It is not told
|
|
87
|
+
* to. The events that survived come back with the count that did not, and with
|
|
88
|
+
* the two things the missing ones would have reported: what is on the desktop
|
|
89
|
+
* now, and what state the machine is in. A model handed an uninterpretable
|
|
90
|
+
* `gap` invents a recovery procedure; this is that procedure, already run.
|
|
91
|
+
*
|
|
92
|
+
* Failures here are swallowed on purpose. This is context attached to an answer
|
|
93
|
+
* the caller already has, and a windows listing that 409s because the guest is
|
|
94
|
+
* busy must not turn a delivered event into a failed tool call.
|
|
95
|
+
*/
|
|
96
|
+
async function reconcile(session, id, signal) {
|
|
97
|
+
const api = session.api.with(signal);
|
|
98
|
+
const state = {};
|
|
99
|
+
const [windows, computer] = await Promise.allSettled([
|
|
100
|
+
api.json('GET', P.computerAction(id, 'windows')),
|
|
101
|
+
api.json('GET', P.computer(id)),
|
|
102
|
+
]);
|
|
103
|
+
if (windows.status === 'fulfilled')
|
|
104
|
+
state.windows_now = windows.value;
|
|
105
|
+
if (computer.status === 'fulfilled') {
|
|
106
|
+
state.computer_now = withoutCredentials(unwrapComputer(computer.value));
|
|
107
|
+
}
|
|
108
|
+
return state;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Why a computer is missing part of the guest half, in words.
|
|
112
|
+
*
|
|
113
|
+
* "The guest half" is not one thing, which is what a client gets wrong here and
|
|
114
|
+
* what this server got wrong until OPL-4221. `file.changed` runs in the
|
|
115
|
+
* terminal broker against libc's own inotify calls, so it needs the terminal
|
|
116
|
+
* channel and NOTHING an image can be missing; the window, clipboard and
|
|
117
|
+
* readiness events need that channel AND the X bindings their desktop watcher
|
|
118
|
+
* is written against. Three shapes fall out of that, and each wants something
|
|
119
|
+
* different done about it — one is fixed by a stop and a start, one is a fact
|
|
120
|
+
* about the image, one is a fact about the host.
|
|
121
|
+
*
|
|
122
|
+
* Written once and used by both wait tools, because the branch was split
|
|
123
|
+
* correctly in one of them and not the other, which is how a two-copy
|
|
124
|
+
* explanation goes wrong.
|
|
125
|
+
*
|
|
126
|
+
* Read off `can` rather than off the computer record, because `can` is what the
|
|
127
|
+
* host actually said and is revised mid-stream by a `capabilities` frame.
|
|
128
|
+
*/
|
|
129
|
+
function guestHalf(can) {
|
|
130
|
+
const files = can.includes('file.changed');
|
|
131
|
+
const desktop = can.some((t) => t.startsWith('window.'));
|
|
132
|
+
if (files && !desktop) {
|
|
133
|
+
return ('The guest half of this stream is more than one capability and this computer has some of ' +
|
|
134
|
+
'it: file.changed needs only the terminal channel its watcher runs over, which this ' +
|
|
135
|
+
'computer has, while window, clipboard and readiness events also need the X bindings their ' +
|
|
136
|
+
'desktop watcher is written against — and this image does not carry those. That is a fact ' +
|
|
137
|
+
'about the image and there is no operation that moves an existing computer onto a newer ' +
|
|
138
|
+
'one, so nothing will make it report those. Use screenshot and list_windows for the desktop.');
|
|
139
|
+
}
|
|
140
|
+
if (desktop && !files) {
|
|
141
|
+
// The reverse of the case above, and the one that reads most like a missing
|
|
142
|
+
// channel while being its opposite: the desktop half needs that channel
|
|
143
|
+
// too, so a computer reporting it plainly has one.
|
|
144
|
+
return ('It does report the desktop half, which needs the same terminal channel a file watch runs ' +
|
|
145
|
+
'over — so the channel is there and it is the file watch that is missing. The host holding ' +
|
|
146
|
+
'this computer predates them (platform OPL-3927), and there is nothing to do about that ' +
|
|
147
|
+
'from here.');
|
|
148
|
+
}
|
|
149
|
+
if (files && desktop) {
|
|
150
|
+
// Both halves present, so whatever was asked for is not a guest capability
|
|
151
|
+
// at all. Reachable only when the platform's vocabulary grows past what this
|
|
152
|
+
// build knows, which the reference says it will — and a paragraph about a
|
|
153
|
+
// missing watcher would be a confident answer to a question nobody asked.
|
|
154
|
+
return ('This computer reports both halves of what a guest observes about itself, so the type you ' +
|
|
155
|
+
'asked for is one it does not emit rather than one it is unable to emit. The vocabulary ' +
|
|
156
|
+
'grows; this build may simply be asking for something newer than the host.');
|
|
157
|
+
}
|
|
158
|
+
return ('This guest has nowhere to run a watcher at all — a Windows one, or a Linux one whose ' +
|
|
159
|
+
'hardware carries no terminal channel — so none of the guest-reported half reaches this ' +
|
|
160
|
+
'stream. The channel is hardware and is acquired on a COLD start, so stop_computer then ' +
|
|
161
|
+
'start_computer can get one where restart_computer cannot. Meanwhile screenshot, ' +
|
|
162
|
+
'list_windows and exec_poll still work.');
|
|
163
|
+
}
|
|
164
|
+
/** One event's type and the thing about it worth putting in a sentence. */
|
|
165
|
+
function name(ev) {
|
|
166
|
+
const data = (ev.data ?? {});
|
|
167
|
+
// `file.changed` wears three payloads, and two of them are not a file: a
|
|
168
|
+
// marker saying this stream's picture of the tree is wrong, and the tree
|
|
169
|
+
// going live. Left to the fallthrough all three read as the bare type, so a
|
|
170
|
+
// wait that ended on "the tree is too big to watch" and one that ended on a
|
|
171
|
+
// file being written said exactly the same thing. wait_for_file_change is
|
|
172
|
+
// careful about this; the general wait advertises the type too and has to be.
|
|
173
|
+
const file = ev.type === 'file.changed'
|
|
174
|
+
? data.armed === true
|
|
175
|
+
? 'now watching this tree — reporting starts here, so re-read it'
|
|
176
|
+
: typeof data.lost === 'string' && data.lost
|
|
177
|
+
? `${data.lost} under ${data.watch} — this stream's picture of that tree is incomplete`
|
|
178
|
+
: `${data.kind} ${data.path}${data.dir ? ', a directory' : ''}`
|
|
179
|
+
: '';
|
|
180
|
+
if (file)
|
|
181
|
+
return `${ev.type} (${file})`;
|
|
182
|
+
const detail = ev.type === 'process.exited'
|
|
183
|
+
? `pid ${data.pid}${data.lost ? ', outcome unknown — the guest lost track of it' : ` exited ${data.exit_code}`}`
|
|
184
|
+
: ev.type === 'window.opened' || ev.type === 'window.focused'
|
|
185
|
+
? String(data.class ?? data.title ?? data.id ?? '')
|
|
186
|
+
: ev.type === 'window.closed' || ev.type === 'window.blurred'
|
|
187
|
+
? String(data.id ?? '')
|
|
188
|
+
: ev.type === 'computer.idle'
|
|
189
|
+
? `${data.idle_seconds}s idle`
|
|
190
|
+
: ev.type.startsWith('computer.')
|
|
191
|
+
? String(data.status ?? '')
|
|
192
|
+
: '';
|
|
193
|
+
return detail ? `${ev.type} (${detail})` : ev.type;
|
|
194
|
+
}
|
|
195
|
+
/** The body these tools answer with, minus the keys there is nothing to say about. */
|
|
196
|
+
function body(id, d, watching) {
|
|
197
|
+
const out = { computer: id, events: d.events, cursor: d.cursor };
|
|
198
|
+
if (d.more)
|
|
199
|
+
out.more_waiting = d.more;
|
|
200
|
+
if (d.loss)
|
|
201
|
+
out.lost = d.loss;
|
|
202
|
+
// On every call rather than only the first, unlike `can_emit` below, because
|
|
203
|
+
// this one is not a constant: a tree arms after the call that nominated it,
|
|
204
|
+
// another call can evict it, and a reconnect can find it disarmed. A reader
|
|
205
|
+
// that had to remember which of four trees was live from a call several turns
|
|
206
|
+
// ago is a reader that will get it wrong.
|
|
207
|
+
if (watching?.length)
|
|
208
|
+
out.watching = watching;
|
|
209
|
+
// Only where it is news. `can_emit` is what stops a model waiting for
|
|
210
|
+
// something this machine will never produce, and the opening frame is the one
|
|
211
|
+
// place that answer exists — but repeating it on every poll would be a field
|
|
212
|
+
// that means nothing on the ninety-ninth call.
|
|
213
|
+
if (d.attached && d.hello) {
|
|
214
|
+
out.can_emit = d.hello.events;
|
|
215
|
+
if (d.hello.windows)
|
|
216
|
+
out.windows_on_attach = d.hello.windows;
|
|
217
|
+
}
|
|
218
|
+
return out;
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* A subscription that has stopped, said once, with whatever it was still
|
|
222
|
+
* holding, and with what to do about it.
|
|
223
|
+
*
|
|
224
|
+
* DRAINED BEFORE THE DROP, which is the whole of the order here. A stream can
|
|
225
|
+
* stop with events still in its ring, and on this platform that is the ordinary
|
|
226
|
+
* case rather than the odd one: listening is not using, so a computer nobody
|
|
227
|
+
* touches suspends underneath its own stream, and the `process.exited` a model
|
|
228
|
+
* went away to wait for is sitting in the buffer when it does. Both gates used
|
|
229
|
+
* to run ahead of every read, so those events went into `drop` unread and the
|
|
230
|
+
* answer was a bare refusal.
|
|
231
|
+
*
|
|
232
|
+
* What made that silent rather than merely late is `resumeCursor`: it is the
|
|
233
|
+
* last cursor DELIVERED, not the last received, so the remembered position was
|
|
234
|
+
* before the unread events and the replacement stream got them back only if the
|
|
235
|
+
* platform could still replay across the stop — which is exactly what a suspend
|
|
236
|
+
* is least likely to allow. Reading here hands them over AND moves that cursor
|
|
237
|
+
* past them, so what is remembered is true whichever way the replay goes.
|
|
238
|
+
*
|
|
239
|
+
* AND THE DROP WAITS FOR AN EMPTY RING, which is the other half of the same
|
|
240
|
+
* point. The drop is what keeps a stopped subscription from answering
|
|
241
|
+
* "suspended" for five minutes after `start_computer` has already fixed it — a
|
|
242
|
+
* model told to fix something, doing so, and being told the same thing again is
|
|
243
|
+
* a model that stops believing the tool. But the drop also destroys the buffer,
|
|
244
|
+
* and this read is bounded by the caller's `limit` while the ring holds up to
|
|
245
|
+
* `MAX_BUFFERED`. Dropping on the first call therefore MOVED the loss rather
|
|
246
|
+
* than removing it: three hundred unread events became a hundred delivered and
|
|
247
|
+
* two hundred discarded, under a sentence calling them the last this stream has
|
|
248
|
+
* and a `more_waiting` that said otherwise (/code-review, OPL-4244). So the stop
|
|
249
|
+
* is reported on every call — which is true every time, and each one hands over
|
|
250
|
+
* another batch — and the subscription goes only when there is nothing left in
|
|
251
|
+
* it. A model that never calls back leaves it to the idle sweep, which is what
|
|
252
|
+
* the sweep is for.
|
|
253
|
+
*/
|
|
254
|
+
function stopped(session, id, reason, sub, read) {
|
|
255
|
+
const d = sub.read(read);
|
|
256
|
+
const n = d.events.length;
|
|
257
|
+
const drained = !d.more;
|
|
258
|
+
if (drained)
|
|
259
|
+
session.events.drop(id, reason, true);
|
|
260
|
+
const held = n
|
|
261
|
+
? `${n} event${n === 1 ? '' : 's'} had already arrived before it stopped and ${n === 1 ? 'is' : 'are'} ` +
|
|
262
|
+
(drained
|
|
263
|
+
? `below — ${n === 1 ? 'it is' : 'they are'} the last this stream has. `
|
|
264
|
+
: `below. ${d.more} more ${d.more === 1 ? 'is' : 'are'} still held here — call again for ` +
|
|
265
|
+
`${d.more === 1 ? 'it' : 'them'} before doing anything else, because they are only in this ` +
|
|
266
|
+
`session and a replay across the stop may not reach them. `)
|
|
267
|
+
: '';
|
|
268
|
+
return refused(`${held}The event stream for ${id} is not running: ${reason}. Fix the cause and call again: ` +
|
|
269
|
+
(drained
|
|
270
|
+
? `the next call opens a fresh stream and resumes from the last event you were handed, so ` +
|
|
271
|
+
`whatever the platform can still replay you will still be given, and whatever it cannot ` +
|
|
272
|
+
`comes back as a stated gap rather than as silence.`
|
|
273
|
+
: `the next call hands over what is still buffered here, and the one after the buffer is ` +
|
|
274
|
+
`empty opens a fresh stream that resumes from the last event you were handed.`),
|
|
275
|
+
// Without the watch set, deliberately. This stream has stopped, so a tree
|
|
276
|
+
// it was carrying is a tree nothing is watching — and `watching` reports
|
|
277
|
+
// `armed` from the last opening frame, which would read as live.
|
|
278
|
+
n || d.loss ? body(id, d) : undefined);
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* A stream that has not yet got as far as its opening frame.
|
|
282
|
+
*
|
|
283
|
+
* Distinct from every other answer here, because the empty list it would
|
|
284
|
+
* otherwise produce is the one sentence this server must not say by accident:
|
|
285
|
+
* "nothing has happened" said by something that was not listening.
|
|
286
|
+
*
|
|
287
|
+
* The subscription is deliberately NOT dropped. It used to be, and that undid
|
|
288
|
+
* the one thing `#url()` was changed to do: a computer that is `starting` or
|
|
289
|
+
* `moving` is weather rather than a refusal, so the loop backs off and keeps
|
|
290
|
+
* asking — and dropping it here threw that progress away every time, so a model
|
|
291
|
+
* following the create-then-wait flow the README advertises paid the full
|
|
292
|
+
* handshake budget again on each call and never got further. Left alone, the
|
|
293
|
+
* loop carries on between turns and the next call finds it further along; the
|
|
294
|
+
* idle sweep is what eventually takes one that never arrives.
|
|
295
|
+
*
|
|
296
|
+
* The budget is reported as what was actually spent, since it is now the
|
|
297
|
+
* caller's deadline that usually ends this rather than {@link ATTACH_MS}.
|
|
298
|
+
*/
|
|
299
|
+
function unattached(id, waitedMs) {
|
|
300
|
+
return refused(`Could not open the event stream for ${id} in ${Math.max(1, Math.round(waitedMs / 1000))}s. This is not ` +
|
|
301
|
+
`an answer about the computer: nothing was listening, so nothing can be said about what it ` +
|
|
302
|
+
`did. The stream is still coming up and this server is still trying — call again, and if it ` +
|
|
303
|
+
`keeps happening, screenshot and list_windows still work.`);
|
|
304
|
+
}
|
|
305
|
+
/** The caller hung up before the stream had opened. Nothing is claimed about the computer. */
|
|
306
|
+
const cancelledDuringAttach = (id) => refused(`Cancelled while the event stream for ${id} was still opening. Nothing was learned about the ` +
|
|
307
|
+
`computer, and the stream is still coming up — call again.`);
|
|
308
|
+
/**
|
|
309
|
+
* The caller hung up while a tree was being put on the wire, or while the guest
|
|
310
|
+
* was being asked to watch it.
|
|
311
|
+
*
|
|
312
|
+
* Its own sentence rather than {@link cancelledDuringAttach}, which says the
|
|
313
|
+
* stream was still opening — true of the attach and not of this. By here the
|
|
314
|
+
* stream is up and it is the WATCH that is not ready, and a model told the
|
|
315
|
+
* wrong one of those would go back to waiting on the stream.
|
|
316
|
+
*/
|
|
317
|
+
const cancelledWhileArming = (id, wire) => refused(`Cancelled while ${wire} on ${id} was being set up to watch. Nothing is claimed about whether ` +
|
|
318
|
+
`it changed — nothing was watching it yet. The nomination stands; call again.`);
|
|
319
|
+
export const registerEvents = (server, session) => {
|
|
320
|
+
server.registerTool('wait_for_event', {
|
|
321
|
+
title: 'Wait for something to happen',
|
|
322
|
+
description: 'Block until the computer reports something, instead of screenshotting in a loop to find out whether it has. This is the tool that replaces polling: a window opening, a background command exiting, the desktop coming up, the machine going idle or changing power state. ' +
|
|
323
|
+
`Types are ${KNOWN_TYPES.join(', ')}, and the list grows — pass none to wait for the next thing of any kind. ` +
|
|
324
|
+
'It returns everything that happened up to and including the match, in order, so the answer is what the computer did rather than one fact out of it. ' +
|
|
325
|
+
'Nothing is lost between calls: this server holds the stream open across turns, so a wait that times out has missed nothing and calling again picks up exactly where it left off. That is why the timeout is short — waiting again is free. ' +
|
|
326
|
+
'Do not use it to wait for a click to land or a page to paint: neither is an event, and a screenshot is still how you find out what the screen looks like.',
|
|
327
|
+
inputSchema: {
|
|
328
|
+
...idArg,
|
|
329
|
+
types: z
|
|
330
|
+
.array(z.string())
|
|
331
|
+
.optional()
|
|
332
|
+
.describe('Wait for any of these. Omit to wait for the next event of any kind. A type this computer cannot emit is refused at once rather than waited on — a guest with no window watcher will never send a window.* and this says so instead of spending your timeout.'),
|
|
333
|
+
pid: z
|
|
334
|
+
.number()
|
|
335
|
+
.int()
|
|
336
|
+
.positive()
|
|
337
|
+
.optional()
|
|
338
|
+
.describe('Only a process.exited for this pid — the one exec with background: true handed you. Without it a wait for process.exited ends on whichever background command finishes first, which on a computer running several is usually not yours.'),
|
|
339
|
+
timeout_s: z
|
|
340
|
+
.number()
|
|
341
|
+
.int()
|
|
342
|
+
.min(1)
|
|
343
|
+
.max(MAX_WAIT_S)
|
|
344
|
+
.default(30)
|
|
345
|
+
.describe('How long to block. Capped below the 60s request timeout most MCP clients ship, because a call that outlives that is cancelled rather than answered late. Nothing is missed by a short wait: call again.'),
|
|
346
|
+
since: z
|
|
347
|
+
.string()
|
|
348
|
+
.optional()
|
|
349
|
+
.describe('A cursor from an earlier call, to start from there instead of from where this session last read. You do not normally need it — with no cursor at all you are handed everything you have not already been given.'),
|
|
350
|
+
limit: z.number().int().min(1).max(500).default(100),
|
|
351
|
+
},
|
|
352
|
+
// Deliberately not readOnlyHint, for the reason exec_poll is not: both of
|
|
353
|
+
// these CONSUME. `sub.read()` advances the model's place in the buffer, so
|
|
354
|
+
// the events it returns are events no later call can return. Clients treat
|
|
355
|
+
// the hint as licence to call without asking and to retry a call that
|
|
356
|
+
// timed out, and a retried read silently drops whatever the first attempt
|
|
357
|
+
// had already taken — the same defect one tool over, with a ring in this
|
|
358
|
+
// session instead of a cursor in the guest. Nothing is created and nothing
|
|
359
|
+
// is destroyed, which is what made the annotation look right.
|
|
360
|
+
}, ({ computer_id, types, pid, timeout_s, since, limit }, extra) => guarded(async () => {
|
|
361
|
+
const id = session.resolve(computer_id);
|
|
362
|
+
const sub = session.events.open(id);
|
|
363
|
+
// ONE deadline for the whole call, armed before the attach rather than
|
|
364
|
+
// after it. `timeout_s` is a promise about when this comes back, and
|
|
365
|
+
// MAX_WAIT_S sits under the 60s most MCP clients give a request — but
|
|
366
|
+
// the attach was a second budget of up to 20s stacked in front of that,
|
|
367
|
+
// so a wait could run to about 75s and be cancelled by the client, which
|
|
368
|
+
// is the exact failure the cap exists to prevent. Measured before the
|
|
369
|
+
// fix: `wait_for_event({timeout_s: 1})` answered after 20.0 seconds.
|
|
370
|
+
const deadline = AbortSignal.timeout(timeout_s * 1000);
|
|
371
|
+
const started = Date.now();
|
|
372
|
+
await attached(sub, extra.signal, deadline);
|
|
373
|
+
// Re-read rather than narrowed once: a subscription can stop at any
|
|
374
|
+
// point in this call, and a `state` captured before the wait is a
|
|
375
|
+
// statement about a moment that has passed.
|
|
376
|
+
const opening = sub.state;
|
|
377
|
+
if (opening.status === 'stopped') {
|
|
378
|
+
return stopped(session, id, opening.reason, sub, { since, limit });
|
|
379
|
+
}
|
|
380
|
+
// A caller who hung up is not a stream that failed to open.
|
|
381
|
+
if (extra.signal?.aborted)
|
|
382
|
+
return cancelledDuringAttach(id);
|
|
383
|
+
if (!sub.eventTypes)
|
|
384
|
+
return unattached(id, Date.now() - started);
|
|
385
|
+
// A `pid` on its own means the exit of THAT command. Without this the
|
|
386
|
+
// filter below reads "anything that is not a process.exited passes",
|
|
387
|
+
// and `wait_for_event({pid: 99})` — which is how the argument's own
|
|
388
|
+
// description reads — ends on the next clipboard change instead.
|
|
389
|
+
const wanted = types?.length
|
|
390
|
+
? new Set(types)
|
|
391
|
+
: pid !== undefined
|
|
392
|
+
? new Set(['process.exited'])
|
|
393
|
+
: undefined;
|
|
394
|
+
// `file.changed` is the one type on this stream that never arrives
|
|
395
|
+
// unasked: a tree has to be NOMINATED on the connection, and without
|
|
396
|
+
// one the platform sends no file events at all. So a wait for it on a
|
|
397
|
+
// stream watching nothing can only end at its timeout, and be reported
|
|
398
|
+
// as "nothing happened" — which is exactly wrong, because nothing was
|
|
399
|
+
// being watched. Waiting longer cannot fix it; only nominating can.
|
|
400
|
+
//
|
|
401
|
+
// Refused only when it is the WHOLE of the request. As one type among
|
|
402
|
+
// several the wait is still worth running — the others can arrive — and
|
|
403
|
+
// refusing it would take a legitimate wait for process.exited away
|
|
404
|
+
// because it happened to mention a file. What that wait must not do is
|
|
405
|
+
// come back saying nothing happened without saying that this half of it
|
|
406
|
+
// was never listening, so the sentence goes on the timeout instead.
|
|
407
|
+
//
|
|
408
|
+
// Measured on ARMED trees and not on nominated ones, because a
|
|
409
|
+
// nomination is not a watch: a tree that is still arming, that the guest
|
|
410
|
+
// called unwatchable, or that the host would not carry produces exactly
|
|
411
|
+
// as many events as no tree at all. Counting one would let each of those
|
|
412
|
+
// suppress the refusal below and hand back a timeout reading "nothing
|
|
413
|
+
// happened".
|
|
414
|
+
//
|
|
415
|
+
// A function rather than a value, because it is asked AFTER the buffered
|
|
416
|
+
// read and after the capability question, by which time a tree can have
|
|
417
|
+
// armed. See the call site for why it is asked there.
|
|
418
|
+
const unwatched = () => Boolean(wanted?.has('file.changed')) && !sub.watching.some((w) => w.armed);
|
|
419
|
+
const nominate = () => {
|
|
420
|
+
const nominated = sub.watching.length;
|
|
421
|
+
return nominated
|
|
422
|
+
? `file.changed is the one event nobody is sent unasked, and the ` +
|
|
423
|
+
`${nominated === 1 ? 'tree' : 'trees'} nominated on ${id} ` +
|
|
424
|
+
`${nominated === 1 ? 'is' : 'are'} not being watched yet — a nomination is accepted ` +
|
|
425
|
+
`at once and the guest is asked afterwards, so there is a window in which no file ` +
|
|
426
|
+
`event can arrive. wait_for_file_change is the call that waits through it and says ` +
|
|
427
|
+
`which of the two you are in.`
|
|
428
|
+
: `file.changed is the one event nobody is sent unasked: a directory has to be ` +
|
|
429
|
+
`nominated on the connection, and nothing on ${id} has one. Use ` +
|
|
430
|
+
`wait_for_file_change, which nominates the directory, waits until the guest is ` +
|
|
431
|
+
`genuinely watching it, and then waits for a change. Once a tree is nominated its ` +
|
|
432
|
+
`file.changed events arrive here like any other event.`;
|
|
433
|
+
};
|
|
434
|
+
const matches = (ev) => {
|
|
435
|
+
if (wanted && !wanted.has(ev.type))
|
|
436
|
+
return false;
|
|
437
|
+
if (pid === undefined)
|
|
438
|
+
return true;
|
|
439
|
+
// A pid filter is about `process.exited` and says nothing about the
|
|
440
|
+
// other types, so a wait for ["window.opened","process.exited"] with
|
|
441
|
+
// a pid still ends on the window. Reading it as a filter over
|
|
442
|
+
// everything would make one argument silently disable another.
|
|
443
|
+
if (ev.type !== 'process.exited')
|
|
444
|
+
return true;
|
|
445
|
+
return ev.data?.pid === pid;
|
|
446
|
+
};
|
|
447
|
+
// Anything already buffered wins before capability is judged. An event
|
|
448
|
+
// this computer has ALREADY sent is not one it cannot send, whatever a
|
|
449
|
+
// later `capabilities` frame says about the guest as it now is.
|
|
450
|
+
//
|
|
451
|
+
// Asked as a function rather than computed once, because capability is
|
|
452
|
+
// not a fact about the moment the wait STARTED. A `capabilities` frame
|
|
453
|
+
// can land mid-wait and withdraw the very type being waited for — a
|
|
454
|
+
// guest half going away is exactly when that happens — and the answer
|
|
455
|
+
// this tool promises for a type the computer cannot emit is an
|
|
456
|
+
// immediate refusal, not the same silence as a quiet computer. Computed
|
|
457
|
+
// once, a withdrawal was indistinguishable from nothing happening and
|
|
458
|
+
// the call sat until its timeout.
|
|
459
|
+
const cannotEmit = () => {
|
|
460
|
+
if (!wanted)
|
|
461
|
+
return undefined;
|
|
462
|
+
const can = sub.eventTypes;
|
|
463
|
+
// An EMPTY list is not a computer that can emit nothing; it is a
|
|
464
|
+
// `hello` that carried no `events` key, which `list(frame.events) ??
|
|
465
|
+
// []` renders identically. Reading it as a refusal would end a healthy
|
|
466
|
+
// wait the moment a reconnect landed on such a frame — and the
|
|
467
|
+
// `capabilities` frame that follows can restore the list, since that
|
|
468
|
+
// frame goes both ways. Unknown is not the same as none.
|
|
469
|
+
if (!can?.length)
|
|
470
|
+
return undefined;
|
|
471
|
+
if (![...wanted].every((t) => !can.includes(t)))
|
|
472
|
+
return undefined;
|
|
473
|
+
return (`${id} cannot emit ${[...wanted].join(' or ')}. It reports it can emit: ` +
|
|
474
|
+
`${can.join(', ')}. ${guestHalf(can)} Either way this wait could only ever have ` +
|
|
475
|
+
`ended at its timeout.`);
|
|
476
|
+
};
|
|
477
|
+
let hit = await sub.waitFor(matches, AbortSignal.abort(), undefined, since);
|
|
478
|
+
if (hit === undefined) {
|
|
479
|
+
const already = cannotEmit();
|
|
480
|
+
if (already)
|
|
481
|
+
return refused(already);
|
|
482
|
+
}
|
|
483
|
+
// AFTER the buffered read and after the capability question, both of
|
|
484
|
+
// which used to sit behind it. A `file.changed` that has already
|
|
485
|
+
// arrived is still an answer even if the tree has since disarmed or
|
|
486
|
+
// been evicted — anything buffered wins before anything about the
|
|
487
|
+
// present is judged, which is the rule two lines up. And a computer
|
|
488
|
+
// that cannot emit file.changed AT ALL is explained by `cannotEmit`;
|
|
489
|
+
// sending that caller to wait_for_file_change would only have it
|
|
490
|
+
// refused there for the reason this call already knew.
|
|
491
|
+
if (hit === undefined && unwatched() && wanted?.size === 1) {
|
|
492
|
+
return refused(`No file.changed can arrive on ${id}'s stream as it stands, however long you wait. ` +
|
|
493
|
+
nominate());
|
|
494
|
+
}
|
|
495
|
+
if (hit === undefined) {
|
|
496
|
+
// The capability question is asked on every wake rather than once
|
|
497
|
+
// before the wait, because a `capabilities` frame can land inside it.
|
|
498
|
+
// Such a frame wakes the waiter but is not an event, so a loop that
|
|
499
|
+
// only re-ran the match saw nothing and parked again — and the answer
|
|
500
|
+
// this tool promises for a type the computer cannot emit is an
|
|
501
|
+
// immediate refusal, not the same silence a quiet computer produces.
|
|
502
|
+
hit = await sub.waitFor(matches, deadline, extra.signal, since, () => cannotEmit() !== undefined);
|
|
503
|
+
}
|
|
504
|
+
const waited = Math.round((Date.now() - started) / 1000);
|
|
505
|
+
if (hit === undefined) {
|
|
506
|
+
if (extra.signal?.aborted) {
|
|
507
|
+
return refused(`Cancelled while waiting on ${id}. The stream is still open and still buffering — ` +
|
|
508
|
+
`nothing was missed.`);
|
|
509
|
+
}
|
|
510
|
+
const now = sub.state;
|
|
511
|
+
if (now.status === 'stopped')
|
|
512
|
+
return stopped(session, id, now.reason, sub, { since, limit });
|
|
513
|
+
// Asked again, because the wait that just ended is long enough for a
|
|
514
|
+
// `capabilities` frame to have arrived inside it. A withdrawal that
|
|
515
|
+
// happened while parked is still the reason nothing came, and saying
|
|
516
|
+
// so beats reporting a quiet few seconds on a computer that can no
|
|
517
|
+
// longer produce this event at all.
|
|
518
|
+
const withdrawn = cannotEmit();
|
|
519
|
+
if (withdrawn)
|
|
520
|
+
return refused(withdrawn);
|
|
521
|
+
// NOT an error, and this is the one place this server's wait tools
|
|
522
|
+
// differ from each other on purpose. `wait_for_computer` timing out
|
|
523
|
+
// means the state it was told to wait for never arrived and may never;
|
|
524
|
+
// this timing out means nothing happened in the last few seconds,
|
|
525
|
+
// which is an answer, and it comes with the cursor that makes asking
|
|
526
|
+
// again cost nothing. Reporting it as a failure would teach a model to
|
|
527
|
+
// stop asking — back to the screenshot loop this tool exists to end.
|
|
528
|
+
const d = sub.read({ since, limit });
|
|
529
|
+
const extras = d.loss ? await reconcile(session, id, extra.signal) : {};
|
|
530
|
+
// What did NOT match still happened, and this read has just handed it
|
|
531
|
+
// over — so the sentence has to name it. Saying "nothing happened"
|
|
532
|
+
// over a payload holding three events is the one thing a model must
|
|
533
|
+
// not be told by a server whose whole promise is that it was
|
|
534
|
+
// listening: it would read the prose, not the JSON, and the events
|
|
535
|
+
// would be delivered and unmentioned in the same breath.
|
|
536
|
+
const others = d.events.length
|
|
537
|
+
? ` ${d.events.length} other event${d.events.length === 1 ? '' : 's'} did happen and ` +
|
|
538
|
+
`${d.events.length === 1 ? 'is' : 'are'} below.`
|
|
539
|
+
: '';
|
|
540
|
+
return said(`Nothing ${wanted ? `matching ${[...wanted].join(' or ')} ` : ''}happened on ${id} in ` +
|
|
541
|
+
`${timeout_s}s.${others} This server kept listening the whole time and is still ` +
|
|
542
|
+
`listening — nothing was missed and nothing is being missed now. Call again to ` +
|
|
543
|
+
`keep waiting.` +
|
|
544
|
+
// Said here rather than as a refusal, because the rest of this
|
|
545
|
+
// wait was real: what must not happen is a model reading "nothing
|
|
546
|
+
// happened" as covering a type nothing was ever going to send.
|
|
547
|
+
(unwatched() ? ` One thing was NOT being waited for: ${nominate()}` : '') +
|
|
548
|
+
(d.loss ? ' Some events were lost before they could be read — see lost.' : ''), { ...body(id, d, sub.watching), ...extras });
|
|
549
|
+
}
|
|
550
|
+
const d = sub.read({ since, limit, through: hit });
|
|
551
|
+
const last = d.events[d.events.length - 1];
|
|
552
|
+
const extras = d.loss ? await reconcile(session, id, extra.signal) : {};
|
|
553
|
+
const before = d.events.length - 1;
|
|
554
|
+
// Empty when another call on this computer consumed the matched event
|
|
555
|
+
// first: the ring is one buffer with one delivered cursor, and two
|
|
556
|
+
// overlapping waits can both match before either reads. Naming an event
|
|
557
|
+
// over an empty list would be an event the caller is never shown.
|
|
558
|
+
if (!last) {
|
|
559
|
+
return said(`Something happened on ${id} and another call on this computer was handed it before ` +
|
|
560
|
+
`this one could read it — the events are in that call's answer, not below. Nothing ` +
|
|
561
|
+
`is lost; look there, or call again for whatever comes next.`, { ...body(id, d, sub.watching), ...extras });
|
|
562
|
+
}
|
|
563
|
+
return said(`${name(last)} on ${id} after ${waited}s` +
|
|
564
|
+
(before > 0 ? `, and ${before} before it` : '') +
|
|
565
|
+
'.' +
|
|
566
|
+
(last.synthesized
|
|
567
|
+
? ' This one is synthesized: the desktop was ALREADY up when this stream attached, ' +
|
|
568
|
+
'and computer.ready is announced once per desktop session — so the real event had ' +
|
|
569
|
+
'happened before there was anything here to hear it, and waiting for it would have ' +
|
|
570
|
+
'waited forever.'
|
|
571
|
+
: ''), { ...body(id, d, sub.watching), ...extras });
|
|
572
|
+
}));
|
|
573
|
+
/**
|
|
574
|
+
* What one `file.changed` says, in a sentence.
|
|
575
|
+
*
|
|
576
|
+
* Three payload shapes wear one type, and the two that are not a file are the
|
|
577
|
+
* ones a model will misread. `lost` is not a failure: it says this stream's
|
|
578
|
+
* picture of the tree is incomplete and the tree is still being watched, so
|
|
579
|
+
* the answer to it is to re-read the directory rather than to give up on the
|
|
580
|
+
* watch. A tool that reported it as an error would teach a model to stop
|
|
581
|
+
* watching a tree that is working.
|
|
582
|
+
*/
|
|
583
|
+
const unwatchable = (wire) => `${wire} is not something this guest can watch: it is not there yet, is not a directory, ` +
|
|
584
|
+
`cannot be read, or is a SYMLINK — links are refused rather than followed, because inotify ` +
|
|
585
|
+
`pins whatever the link resolved to and repointing it afterwards produces no event at all. ` +
|
|
586
|
+
`Name the real directory. Nominating one a job is about to create is fine and the nomination ` +
|
|
587
|
+
`stands: the watch starts by itself when the directory appears, so calling again later will ` +
|
|
588
|
+
`find it armed.`;
|
|
589
|
+
/**
|
|
590
|
+
* Why this tree is not one this call can answer about, if it is not.
|
|
591
|
+
*
|
|
592
|
+
* The four conditions the change wait abandons on, and the same four the two
|
|
593
|
+
* waits ahead of it now give up on rather than parking through — so they have
|
|
594
|
+
* to be reported the same way from both, in the same ORDER. Getting the order
|
|
595
|
+
* wrong is not cosmetic: a tree withheld because the host would not carry it
|
|
596
|
+
* is also a tree missing from the nomination set, so a membership check ahead
|
|
597
|
+
* of the refusal check calls a rejected watch an eviction and tells the
|
|
598
|
+
* caller somebody else's fifth directory pushed theirs out.
|
|
599
|
+
*
|
|
600
|
+
* `undefined` when none of them holds, which is when the tree really is just
|
|
601
|
+
* still coming up.
|
|
602
|
+
*/
|
|
603
|
+
const settled = (sub, id, root, wire,
|
|
604
|
+
// A FUNCTION, because a caller's tail can CONSUME something — the
|
|
605
|
+
// once-only interruption note — and an argument is evaluated whether or not
|
|
606
|
+
// this returns anything. Passed as a value it took that note on every call
|
|
607
|
+
// that reached here and printed it only on the calls that settled, which is
|
|
608
|
+
// the defect the note's own laziness exists to prevent, moved one frame up.
|
|
609
|
+
tail, extras = {}) => {
|
|
610
|
+
const answer = { computer: id, watch: wire, watching: sub.watching, ...extras };
|
|
611
|
+
if (sub.watchWasRefused(root)) {
|
|
612
|
+
return refused(`${id} would not open an event stream carrying ${root}, so this server has stopped asking ` +
|
|
613
|
+
`for it — it dropped the tree, the same stream opened without it, and that is how it ` +
|
|
614
|
+
`knows. A watch is the one thing on that connection a host refuses outright, and it ` +
|
|
615
|
+
`does so where a websocket client is told nothing at all, so the reason is one of two: ` +
|
|
616
|
+
`this computer is already watching the 32 trees it will watch at once across every ` +
|
|
617
|
+
`client connected to it, or it will not honour this path. Nominate a directory it is ` +
|
|
618
|
+
`already watching, close another client, or use exec to look at this one.` +
|
|
619
|
+
tail(), answer);
|
|
620
|
+
}
|
|
621
|
+
if (!sub.nominates(root)) {
|
|
622
|
+
return refused(`${wire} on ${id} stopped being watched while this call was waiting: another call ` +
|
|
623
|
+
`nominated a fifth tree and this was the one it pushed out. Nothing can be said about ` +
|
|
624
|
+
`whether it changed after that. Call again to nominate it back.` +
|
|
625
|
+
tail(), answer);
|
|
626
|
+
}
|
|
627
|
+
const can = sub.eventTypes;
|
|
628
|
+
if (can?.length && !can.includes('file.changed')) {
|
|
629
|
+
return refused(`${id} stopped being able to report file changes while this call was waiting — the guest ` +
|
|
630
|
+
`half of its event stream was withdrawn, which is what a guest turning out to have no ` +
|
|
631
|
+
`watcher looks like. It now reports it can emit: ${can.join(', ')}. Nothing can be said ` +
|
|
632
|
+
`about whether ${wire} changed. Use exec to look at the directory.` +
|
|
633
|
+
tail(), answer);
|
|
634
|
+
}
|
|
635
|
+
if (sub.lostFor(root) === 'unwatchable') {
|
|
636
|
+
return refused(unwatchable(wire) + tail(), answer);
|
|
637
|
+
}
|
|
638
|
+
return undefined;
|
|
639
|
+
};
|
|
640
|
+
const changeLine = (ev, id) => {
|
|
641
|
+
const d = (ev.data ?? {});
|
|
642
|
+
const lost = typeof d.lost === 'string' ? d.lost : '';
|
|
643
|
+
if (lost === 'flood') {
|
|
644
|
+
return (`${d.watch} on ${id} changed faster than this stream reports, so what happened is one ` +
|
|
645
|
+
`marker instead of thousands of events. The watch is still on and the tree is still being ` +
|
|
646
|
+
`watched — this is not a failure and there is nothing to fix. What it costs you is your ` +
|
|
647
|
+
`picture of the tree: list the directory with exec to re-read it, and carry on waiting. A ` +
|
|
648
|
+
`build under a watched path does this every time.`);
|
|
649
|
+
}
|
|
650
|
+
if (lost === 'budget') {
|
|
651
|
+
return (`${d.watch} on ${id} is bigger than the directory budget one watch gets, so part of it is ` +
|
|
652
|
+
`not being watched at all and changes down there will never be reported. This one does ` +
|
|
653
|
+
`not clear by waiting: call again with a narrower path — the subdirectory you actually ` +
|
|
654
|
+
`care about — and re-read the tree with exec for what you missed.`);
|
|
655
|
+
}
|
|
656
|
+
if (lost) {
|
|
657
|
+
return (`${d.watch} on ${id} reported ${JSON.stringify(lost)}, which this build does not know the ` +
|
|
658
|
+
`meaning of. Treat any non-empty lost as "my picture of this tree is wrong" and re-read ` +
|
|
659
|
+
`the directory with exec.`);
|
|
660
|
+
}
|
|
661
|
+
return `${d.kind} ${d.path}${d.dir ? ' (a directory)' : ''} on ${id}`;
|
|
662
|
+
};
|
|
663
|
+
server.registerTool('wait_for_file_change', {
|
|
664
|
+
title: 'Wait for a file to change',
|
|
665
|
+
description: 'Block until something is created, changed or deleted anywhere under a directory in the guest, instead of running ls in a loop to find out whether it has. This is how you wait for a build to write its output, a download to land, or a script to produce a file. ' +
|
|
666
|
+
'It nominates the directory on this computer\'s event stream, waits until the guest is genuinely watching it, and only then waits for a change — so a timeout from this tool means nothing changed, never "nothing was watching yet". ' +
|
|
667
|
+
`The nomination sticks: up to ${MAX_WATCHES} trees stay watched across your turns, so a second call on the same path is instant and a change that happens between two of your turns is still waiting for you. ` +
|
|
668
|
+
'Nominate the NARROWEST directory you care about. A home directory under a build is thousands of changes a second, and what you get back for one of those is a single "too much changed" marker rather than the events. ' +
|
|
669
|
+
'It reports changes, not contents: read_file and exec are still how you find out what is in a file. A rename inside the tree arrives as a delete and a create, and nothing is announced about what was already there when you nominated it — list the directory for that.',
|
|
670
|
+
inputSchema: {
|
|
671
|
+
...idArg,
|
|
672
|
+
path: z
|
|
673
|
+
.string()
|
|
674
|
+
.describe('An absolute directory in the guest, watched all the way down. Not a file and not a glob — name the directory and filter the changes yourself. A trailing slash or a . segment is cleaned away, and the cleaned form is what the events carry.'),
|
|
675
|
+
timeout_s: z
|
|
676
|
+
.number()
|
|
677
|
+
.int()
|
|
678
|
+
.min(1)
|
|
679
|
+
.max(MAX_WAIT_S)
|
|
680
|
+
.default(30)
|
|
681
|
+
.describe('How long this call may take in total, attaching and arming included — not time spent watching, which is less and is reported back. Capped below the 60s request timeout most MCP clients ship. A call that spends it all arming says so rather than reporting a quiet directory, and nothing is missed by a short wait: the tree stays watched between calls, so call again.'),
|
|
682
|
+
since: z
|
|
683
|
+
.string()
|
|
684
|
+
.optional()
|
|
685
|
+
.describe('A cursor from an earlier call, to start from there instead of from where this session last read. You do not normally need it.'),
|
|
686
|
+
limit: z.number().int().min(1).max(500).default(100),
|
|
687
|
+
},
|
|
688
|
+
// Not readOnlyHint, for the two reasons the tools above are not. This
|
|
689
|
+
// CONSUMES — `sub.read()` advances the model's place in the buffer — and
|
|
690
|
+
// it also CONFIGURES: a nomination reopens the stream and can push another
|
|
691
|
+
// tree out of the watch set. A client treating the hint as licence to
|
|
692
|
+
// retry a call that timed out would silently drop events and, on the
|
|
693
|
+
// fifth distinct path, silently stop watching the first.
|
|
694
|
+
}, ({ computer_id, path, timeout_s, since, limit }, extra) => guarded(async () => {
|
|
695
|
+
const id = session.resolve(computer_id);
|
|
696
|
+
// Before anything is opened. A path this host will not accept is a 400
|
|
697
|
+
// on the UPGRADE, and a failed upgrade reaches a websocket client as an
|
|
698
|
+
// error with no status and no body — indistinguishable from a host that
|
|
699
|
+
// is down. Sent optimistically it would be a reconnect loop under
|
|
700
|
+
// "could not open the event stream" for a mistake visible from here.
|
|
701
|
+
let root;
|
|
702
|
+
try {
|
|
703
|
+
root = cleanWatchPath(path);
|
|
704
|
+
}
|
|
705
|
+
catch (err) {
|
|
706
|
+
return refused(`${err instanceof Error ? err.message : String(err)} Nothing was opened and nothing on ` +
|
|
707
|
+
`${id} was changed by this call.`);
|
|
708
|
+
}
|
|
709
|
+
const renamed = root === path
|
|
710
|
+
? ''
|
|
711
|
+
: ` ${JSON.stringify(path)} is the same directory as ${root}, which is the spelling ` +
|
|
712
|
+
`every event carries — match on that one.`;
|
|
713
|
+
const sub = session.events.open(id);
|
|
714
|
+
const deadline = AbortSignal.timeout(timeout_s * 1000);
|
|
715
|
+
const started = Date.now();
|
|
716
|
+
await attached(sub, extra.signal, deadline);
|
|
717
|
+
const opening = sub.state;
|
|
718
|
+
if (opening.status === 'stopped') {
|
|
719
|
+
return stopped(session, id, opening.reason, sub, { since, limit });
|
|
720
|
+
}
|
|
721
|
+
if (extra.signal?.aborted)
|
|
722
|
+
return cancelledDuringAttach(id);
|
|
723
|
+
if (!sub.eventTypes)
|
|
724
|
+
return unattached(id, Date.now() - started);
|
|
725
|
+
// Asked before the tree is nominated, because a computer that cannot
|
|
726
|
+
// report file changes will accept the nomination and then say nothing,
|
|
727
|
+
// which is the silence this whole tool exists to not produce. An EMPTY
|
|
728
|
+
// list is unknown rather than none — see the same reading in
|
|
729
|
+
// wait_for_event.
|
|
730
|
+
const can = sub.eventTypes;
|
|
731
|
+
if (can.length && !can.includes('file.changed')) {
|
|
732
|
+
return refused(`${id} cannot emit file.changed, so nothing here can watch a directory on it. It ` +
|
|
733
|
+
`reports it can emit: ${can.join(', ')}. ${guestHalf(can)} To find out whether a ` +
|
|
734
|
+
`file has appeared on this computer, run ls with exec.`);
|
|
735
|
+
}
|
|
736
|
+
const nomination = sub.nominate(root);
|
|
737
|
+
const evicted = nomination.evicted
|
|
738
|
+
? ` ${nomination.evicted} is no longer being watched: a stream watches at most ` +
|
|
739
|
+
`${MAX_WATCHES} trees and it was the one you had asked about least recently. Nominate ` +
|
|
740
|
+
`it again if you still need it.`
|
|
741
|
+
: '';
|
|
742
|
+
// A new tree reopens the connection, so what this call waits for is the
|
|
743
|
+
// OPEN connection carrying it and having greeted — not merely the next
|
|
744
|
+
// opening frame, which can belong to a reconnect that was already in
|
|
745
|
+
// flight when the nomination was made and never carried it. `attached`
|
|
746
|
+
// above cannot serve either: it is satisfied by the frame this
|
|
747
|
+
// connection has already had.
|
|
748
|
+
if (!sub.nominationLive(root)) {
|
|
749
|
+
await sub.nominated(root, deadline, extra.signal);
|
|
750
|
+
const after = sub.state;
|
|
751
|
+
if (after.status === 'stopped') {
|
|
752
|
+
return stopped(session, id, after.reason, sub, { since, limit });
|
|
753
|
+
}
|
|
754
|
+
if (extra.signal?.aborted)
|
|
755
|
+
return cancelledWhileArming(id, root);
|
|
756
|
+
// ASKED FIRST, all four of them, because each is an answer and the
|
|
757
|
+
// timeout below is only the absence of one. A tree the host would not
|
|
758
|
+
// carry, one another call evicted, or a computer that has stopped
|
|
759
|
+
// being able to report file changes at all are none of them "the
|
|
760
|
+
// stream has not come back yet".
|
|
761
|
+
const settledEarly = settled(sub, id, root, sub.hostPath(root), () => renamed + evicted);
|
|
762
|
+
if (settledEarly)
|
|
763
|
+
return settledEarly;
|
|
764
|
+
if (!sub.nominationLive(root)) {
|
|
765
|
+
return refused(`Could not start watching ${root} on ${id} within ${timeout_s}s. The stream has to ` +
|
|
766
|
+
`be reopened to carry a new tree and the new connection has not come back yet, so ` +
|
|
767
|
+
`this is not an answer about the directory — nothing was watching it. This server ` +
|
|
768
|
+
`is still reconnecting between your turns; call again, and give it longer.` +
|
|
769
|
+
evicted);
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
// A host that predates file watches ignores `&watch=` rather than
|
|
773
|
+
// refusing it, so the socket opens, nothing is watched, and no event
|
|
774
|
+
// ever arrives. That silence is indistinguishable from a quiet
|
|
775
|
+
// directory, and the opening frame is the only place it can be told
|
|
776
|
+
// apart — so it is told apart here rather than at a timeout.
|
|
777
|
+
if (sub.watchesHonoured === false) {
|
|
778
|
+
return refused(`${id} opened its event stream but said nothing about ${root}, so this server cannot ` +
|
|
779
|
+
`tell whether the tree is being watched — and a wait would be a wait on silence. The ` +
|
|
780
|
+
`host holding this computer may predate file watches (platform OPL-3927). Use exec ` +
|
|
781
|
+
`to look at the directory instead.` +
|
|
782
|
+
evicted);
|
|
783
|
+
}
|
|
784
|
+
const wire = sub.hostPath(root);
|
|
785
|
+
if (!sub.isArmed(root))
|
|
786
|
+
await sub.armedWait(root, deadline, extra.signal);
|
|
787
|
+
if (!sub.isArmed(root)) {
|
|
788
|
+
const now = sub.state;
|
|
789
|
+
if (now.status === 'stopped')
|
|
790
|
+
return stopped(session, id, now.reason, sub, { since, limit });
|
|
791
|
+
if (extra.signal?.aborted)
|
|
792
|
+
return cancelledWhileArming(id, wire);
|
|
793
|
+
// Asked before the sentence below, all four of them, because that
|
|
794
|
+
// sentence says the nomination stands and this tree is still coming
|
|
795
|
+
// up — and every word of it is false when the tree has been evicted
|
|
796
|
+
// by another call, given up on as one this host will not carry, or
|
|
797
|
+
// when the computer has stopped being able to report file changes.
|
|
798
|
+
const why = settled(sub, id, root, wire, () => renamed + evicted);
|
|
799
|
+
if (why)
|
|
800
|
+
return why;
|
|
801
|
+
// The one answer this tool must never give as "nothing changed".
|
|
802
|
+
// inotify reports changes and not state, so anything that happened
|
|
803
|
+
// before the watch armed was never reported and never will be —
|
|
804
|
+
// saying nothing changed over that window would be a claim about a
|
|
805
|
+
// stretch of time during which nobody was looking.
|
|
806
|
+
return refused(`${wire} on ${id} is not being watched yet after ${Math.round((Date.now() - started) / 1000)}s, ` +
|
|
807
|
+
`so NOTHING can be said about whether it changed. Arming is not instant: the guest ` +
|
|
808
|
+
`has to be asked, and on a computer nobody has opened a terminal on the watcher is ` +
|
|
809
|
+
`installed into the guest first. It is still coming up and the nomination stands — ` +
|
|
810
|
+
`call again and it will be waiting properly. Do not read this as "nothing changed".` +
|
|
811
|
+
renamed +
|
|
812
|
+
evicted, { computer: id, watch: wire, watching: sub.watching });
|
|
813
|
+
}
|
|
814
|
+
// Taken after the tree is confirmed live, so the arming this call
|
|
815
|
+
// waited for is not the one it abandons on. A LATER arming is news: it
|
|
816
|
+
// says the watch was interrupted and is reporting from here, so
|
|
817
|
+
// whatever happened in between was never reported and the tree has to
|
|
818
|
+
// be re-read.
|
|
819
|
+
const generation = sub.armGeneration(root);
|
|
820
|
+
// Said once, on the first answer about a tree this subscription
|
|
821
|
+
// inherited from one that went away. A tree is watched by the
|
|
822
|
+
// CONNECTION, so the idle reap that took the previous subscription also
|
|
823
|
+
// stopped the guest watching it — and inotify reports changes and not
|
|
824
|
+
// state, so nothing that happened in between was recorded anywhere for a
|
|
825
|
+
// replay to hand back. Re-nominating gets the watch going again and says
|
|
826
|
+
// nothing about the hole, which would leave a model reading an entirely
|
|
827
|
+
// ordinary "nothing changed" over minutes during which nothing looked.
|
|
828
|
+
// A FUNCTION, and the flag is taken where the note is RENDERED rather
|
|
829
|
+
// than here. Taken up front it was consumed by every answer this call
|
|
830
|
+
// could give and printed by only some of them — a cancel, a stream
|
|
831
|
+
// between connections, a tree another call had evicted — so the one
|
|
832
|
+
// thing it exists to say was thrown away, and the next call, which is
|
|
833
|
+
// the one that finally reports a quiet directory, had nothing to say
|
|
834
|
+
// about the minutes during which nothing was watching. This way an
|
|
835
|
+
// answer that does not print it defers it rather than losing it.
|
|
836
|
+
const interrupted = () => sub.takeInterruption(root)
|
|
837
|
+
? ` Note: this tree was NOT being watched between an earlier call and this one — the ` +
|
|
838
|
+
`stream carrying it was closed for want of anything asking about this computer, and ` +
|
|
839
|
+
`a watch lives on the connection. Anything that changed in that window was never ` +
|
|
840
|
+
`reported and cannot be. Re-read the directory with exec if it matters.`
|
|
841
|
+
: '';
|
|
842
|
+
// When the waiting actually started, which is not when the call did.
|
|
843
|
+
// `timeout_s` bounds the whole call — it has to, because a client
|
|
844
|
+
// cancels a request that outlives its own timeout — so a call that
|
|
845
|
+
// spent most of it attaching and arming watched for less than it asked
|
|
846
|
+
// for, and saying otherwise would overstate the window this answer
|
|
847
|
+
// covers.
|
|
848
|
+
const armedAt = Date.now();
|
|
849
|
+
// A `capabilities` frame can withdraw the guest half mid-wait; a guest
|
|
850
|
+
// that turns out to have no watcher is exactly when that happens. The
|
|
851
|
+
// frame wakes every parked waiter but is not an event, so a loop that
|
|
852
|
+
// only re-ran the match would see nothing, park again, and sit out the
|
|
853
|
+
// deadline on a computer that could no longer produce what it was
|
|
854
|
+
// waiting for. `wait_for_event` asks the same question for the same
|
|
855
|
+
// reason.
|
|
856
|
+
const withdrawn = () => {
|
|
857
|
+
const types = sub.eventTypes;
|
|
858
|
+
// An EMPTY list is unknown rather than none — a `hello` that carried
|
|
859
|
+
// no `events` key reads identically — so it is not a withdrawal. The
|
|
860
|
+
// same reading as wait_for_event's.
|
|
861
|
+
return Boolean(types?.length) && !types?.includes('file.changed');
|
|
862
|
+
};
|
|
863
|
+
const isChange = (ev) => {
|
|
864
|
+
if (ev.type !== 'file.changed')
|
|
865
|
+
return false;
|
|
866
|
+
const d = ev.data;
|
|
867
|
+
if (d?.watch !== wire)
|
|
868
|
+
return false;
|
|
869
|
+
if (typeof d.lost === 'string' && d.lost)
|
|
870
|
+
return true;
|
|
871
|
+
return typeof d.path === 'string' && Boolean(d.path);
|
|
872
|
+
};
|
|
873
|
+
const hit = await sub.waitFor(isChange, deadline, extra.signal, since,
|
|
874
|
+
// Four ways for a wait to stop being about the tree it started on,
|
|
875
|
+
// and every one of them is an answer rather than silence. A re-arm
|
|
876
|
+
// says reporting begins again HERE, so the gap was never reported. A
|
|
877
|
+
// disarm says the tree is not being watched at all any more. An
|
|
878
|
+
// eviction says another call took its place in the watch set. A
|
|
879
|
+
// withdrawn capability says this computer can no longer report file
|
|
880
|
+
// changes whatever is nominated. Any of the four run to the deadline
|
|
881
|
+
// would come back as "nothing changed under this tree", which is the
|
|
882
|
+
// sentence this whole tool exists not to say about a window nobody
|
|
883
|
+
// was watching.
|
|
884
|
+
() => sub.armGeneration(root) !== generation ||
|
|
885
|
+
!sub.isArmed(root) ||
|
|
886
|
+
!sub.nominates(root) ||
|
|
887
|
+
withdrawn());
|
|
888
|
+
const waited = Math.round((Date.now() - armedAt) / 1000);
|
|
889
|
+
if (hit === undefined) {
|
|
890
|
+
if (extra.signal?.aborted) {
|
|
891
|
+
// Deliberately not "the tree is still being watched", which this
|
|
892
|
+
// call did not check and which a cancel racing an eviction or a
|
|
893
|
+
// shed makes false. What IS true is the part that matters: the
|
|
894
|
+
// buffer is on this side and nothing in it went anywhere.
|
|
895
|
+
return refused(`Cancelled while waiting on ${wire}. Nothing was missed by the cancellation — this ` +
|
|
896
|
+
`server holds the stream and its buffer between calls — but nothing was checked ` +
|
|
897
|
+
`about the watch either, so call again for an answer about the tree.` +
|
|
898
|
+
interrupted());
|
|
899
|
+
}
|
|
900
|
+
const now = sub.state;
|
|
901
|
+
if (now.status === 'stopped')
|
|
902
|
+
return stopped(session, id, now.reason, sub, { since, limit });
|
|
903
|
+
const d = sub.read({ since, limit });
|
|
904
|
+
const extras = d.loss ? await reconcile(session, id, extra.signal) : {};
|
|
905
|
+
const answer = { ...body(id, d, sub.watching), watch: wire, ...extras };
|
|
906
|
+
// THESE FIRST, and in this order, because each would otherwise be
|
|
907
|
+
// described as something else. Evicting a tree does not reset its arm
|
|
908
|
+
// generation — that is kept monotonic on purpose — but it does take
|
|
909
|
+
// the tree out of the watch set, and the generation branch below would
|
|
910
|
+
// call that a re-arm and invite the caller to go on waiting on a tree
|
|
911
|
+
// nothing nominates. A tree the host would not carry is missing from
|
|
912
|
+
// the set for a quite different reason, which is why the refusal is
|
|
913
|
+
// asked about ahead of the membership.
|
|
914
|
+
const why = settled(sub, id, root, wire, () => interrupted() + evicted, {
|
|
915
|
+
...body(id, d, sub.watching),
|
|
916
|
+
...extras,
|
|
917
|
+
});
|
|
918
|
+
if (why)
|
|
919
|
+
return why;
|
|
920
|
+
if (sub.armGeneration(root) !== generation) {
|
|
921
|
+
return said(`The watch on ${wire} was re-armed after an interruption — a stop and a start, a ` +
|
|
922
|
+
`guest reboot, a broker replaced. Reporting starts again HERE, and nothing that ` +
|
|
923
|
+
`happened to the tree while it was down was reported or ever will be. Re-read the ` +
|
|
924
|
+
`directory with exec if that window matters, then call again to keep waiting.` +
|
|
925
|
+
interrupted() +
|
|
926
|
+
evicted, answer);
|
|
927
|
+
}
|
|
928
|
+
if (!sub.isArmed(root)) {
|
|
929
|
+
return refused(`${wire} on ${id} stopped being watched while this call was waiting, so NOTHING can ` +
|
|
930
|
+
`be said about whether anything changed under it after that — do not read this as ` +
|
|
931
|
+
`"nothing changed". The stream is being reopened here and the nomination stands; ` +
|
|
932
|
+
`call again and it will be waiting properly.` +
|
|
933
|
+
interrupted() +
|
|
934
|
+
evicted, answer);
|
|
935
|
+
}
|
|
936
|
+
// A tree that does not FIT its watch is not a tree a silence is an
|
|
937
|
+
// answer about. `budget` says part of it is not being watched at all,
|
|
938
|
+
// permanently, and unlike a flood that does not clear by waiting — so
|
|
939
|
+
// every later call would otherwise report a confident "nothing
|
|
940
|
+
// changed" over a subtree nobody is looking at.
|
|
941
|
+
if (sub.lostFor(root) === 'budget') {
|
|
942
|
+
return refused(`Nothing changed in the part of ${wire} that is being watched, but that is not the ` +
|
|
943
|
+
`whole tree: it is bigger than the directory budget one watch gets, so changes ` +
|
|
944
|
+
`deeper in it are not reported and a silence here is not an answer about the ` +
|
|
945
|
+
`directory. This does not clear by waiting. Call again with a narrower path — the ` +
|
|
946
|
+
`subdirectory you actually care about — and re-read this one with exec.` +
|
|
947
|
+
interrupted() +
|
|
948
|
+
renamed, answer);
|
|
949
|
+
}
|
|
950
|
+
// The stream can be between connections at this instant, and a tree on
|
|
951
|
+
// a connection that is not up is not one being watched — however
|
|
952
|
+
// briefly. Claiming otherwise is the half of the sentence below that
|
|
953
|
+
// can actually be checked, so it is checked.
|
|
954
|
+
if (!sub.watchLive(root)) {
|
|
955
|
+
return refused(`The event stream for ${id} is reopening, so ${wire} is not being watched at this ` +
|
|
956
|
+
`moment and nothing can be said about the last few seconds — do not read this as ` +
|
|
957
|
+
`"nothing changed". The nomination stands and this server is still reconnecting ` +
|
|
958
|
+
`between your turns; call again.` +
|
|
959
|
+
interrupted() +
|
|
960
|
+
evicted, answer);
|
|
961
|
+
}
|
|
962
|
+
// NOT an error, for the reason wait_for_event's timeout is not: the
|
|
963
|
+
// tree was being watched for the whole of it, so "nothing changed" is
|
|
964
|
+
// an answer rather than an absence of one. This is the sentence the
|
|
965
|
+
// arming gate above exists to make true — and the interval is the one
|
|
966
|
+
// actually spent watching, not the timeout that was asked for.
|
|
967
|
+
const others = d.events.length
|
|
968
|
+
? ` ${d.events.length} other event${d.events.length === 1 ? '' : 's'} did happen and ` +
|
|
969
|
+
`${d.events.length === 1 ? 'is' : 'are'} below.`
|
|
970
|
+
: '';
|
|
971
|
+
return said(`Nothing changed under ${wire} on ${id} in the ${waited}s it spent watching.${others} ` +
|
|
972
|
+
`The tree was being watched for the whole of that and still is, so this is an ` +
|
|
973
|
+
`answer rather than a gap. Call again to keep waiting; nothing is missed between ` +
|
|
974
|
+
`calls.` +
|
|
975
|
+
interrupted() +
|
|
976
|
+
renamed +
|
|
977
|
+
evicted +
|
|
978
|
+
(d.loss ? ' Some events were lost before they could be read — see lost.' : ''), answer);
|
|
979
|
+
}
|
|
980
|
+
const d = sub.read({ since, limit, through: hit });
|
|
981
|
+
const last = d.events[d.events.length - 1];
|
|
982
|
+
const extras = d.loss ? await reconcile(session, id, extra.signal) : {};
|
|
983
|
+
const earlier = d.events.length - 1;
|
|
984
|
+
// The one `lost` that is not a re-read: it says the tree is not being
|
|
985
|
+
// watched, so it is the request failing rather than the watch reporting.
|
|
986
|
+
// It arrives as an ordinary file.changed and would otherwise be
|
|
987
|
+
// described by changeLine, which has nothing useful to say about it.
|
|
988
|
+
if ((last?.data?.lost ?? '') === 'unwatchable') {
|
|
989
|
+
return refused(`${unwatchable(wire)} It was being watched until now; from here it is not.` +
|
|
990
|
+
interrupted() +
|
|
991
|
+
renamed +
|
|
992
|
+
evicted, { ...body(id, d, sub.watching), watch: wire, ...extras });
|
|
993
|
+
}
|
|
994
|
+
// A `through` read can come back empty when another call on this
|
|
995
|
+
// computer consumed the matched event first — the ring is one buffer
|
|
996
|
+
// with one delivered cursor, and two overlapping waits can both match
|
|
997
|
+
// before either reads. Announcing "a change" over an empty list would be
|
|
998
|
+
// a change the caller is never shown.
|
|
999
|
+
if (!last) {
|
|
1000
|
+
return said(`Something changed under ${wire} on ${id}, and another call on this computer was ` +
|
|
1001
|
+
`handed it before this one could read it — the events are in that call's answer, ` +
|
|
1002
|
+
`not below. Nothing is lost; look there, or call again for whatever comes next.` +
|
|
1003
|
+
interrupted() +
|
|
1004
|
+
renamed +
|
|
1005
|
+
evicted, { ...body(id, d, sub.watching), watch: wire, ...extras });
|
|
1006
|
+
}
|
|
1007
|
+
return said(`${changeLine(last, id)} after ${waited}s` +
|
|
1008
|
+
(earlier > 0 ? `, and ${earlier} event${earlier === 1 ? '' : 's'} before it` : '') +
|
|
1009
|
+
'.' +
|
|
1010
|
+
interrupted() +
|
|
1011
|
+
renamed +
|
|
1012
|
+
evicted, { ...body(id, d, sub.watching), watch: wire, ...extras });
|
|
1013
|
+
}));
|
|
1014
|
+
server.registerTool('poll_events', {
|
|
1015
|
+
title: 'Read what has happened',
|
|
1016
|
+
description: 'Everything the computer has reported that you have not been handed yet, without waiting. This server holds the event stream open between your turns, so this drains what accumulated while you were doing something else — including while you were running other tools on the same machine. Use it after a long exec, or whenever you want to know what changed without spending a screenshot. Returns immediately, and an empty list genuinely means nothing has happened.',
|
|
1017
|
+
inputSchema: {
|
|
1018
|
+
...idArg,
|
|
1019
|
+
since: z
|
|
1020
|
+
.string()
|
|
1021
|
+
.optional()
|
|
1022
|
+
.describe('A cursor from an earlier call. Omit it and you get everything since the last time you read, which is what you usually want.'),
|
|
1023
|
+
limit: z
|
|
1024
|
+
.number()
|
|
1025
|
+
.int()
|
|
1026
|
+
.min(1)
|
|
1027
|
+
.max(500)
|
|
1028
|
+
.default(100)
|
|
1029
|
+
.describe('At most this many, oldest first. The rest stay buffered and come back on the next call — more_waiting says how many.'),
|
|
1030
|
+
},
|
|
1031
|
+
// Deliberately not readOnlyHint, for the reason exec_poll is not: both of
|
|
1032
|
+
// these CONSUME. `sub.read()` advances the model's place in the buffer, so
|
|
1033
|
+
// the events it returns are events no later call can return. Clients treat
|
|
1034
|
+
// the hint as licence to call without asking and to retry a call that
|
|
1035
|
+
// timed out, and a retried read silently drops whatever the first attempt
|
|
1036
|
+
// had already taken — the same defect one tool over, with a ring in this
|
|
1037
|
+
// session instead of a cursor in the guest. Nothing is created and nothing
|
|
1038
|
+
// is destroyed, which is what made the annotation look right.
|
|
1039
|
+
}, ({ computer_id, since, limit }, extra) => guarded(async () => {
|
|
1040
|
+
const id = session.resolve(computer_id);
|
|
1041
|
+
const sub = session.events.open(id);
|
|
1042
|
+
// The one thing this tool waits for. Opening a socket takes a round
|
|
1043
|
+
// trip for the URL and another for the handshake, and a first call that
|
|
1044
|
+
// answered "nothing has happened" before either had finished would be
|
|
1045
|
+
// saying something it does not know.
|
|
1046
|
+
const started = Date.now();
|
|
1047
|
+
await attached(sub, extra.signal);
|
|
1048
|
+
const state = sub.state;
|
|
1049
|
+
if (state.status === 'stopped') {
|
|
1050
|
+
return stopped(session, id, state.reason, sub, { since, limit });
|
|
1051
|
+
}
|
|
1052
|
+
if (extra.signal?.aborted)
|
|
1053
|
+
return cancelledDuringAttach(id);
|
|
1054
|
+
if (!sub.eventTypes)
|
|
1055
|
+
return unattached(id, Date.now() - started);
|
|
1056
|
+
const d = sub.read({ since, limit });
|
|
1057
|
+
const extras = d.loss ? await reconcile(session, id, extra.signal) : {};
|
|
1058
|
+
if (!d.events.length) {
|
|
1059
|
+
// The one case where "this is an answer rather than a gap" is exactly
|
|
1060
|
+
// wrong: a gap whose surviving events were all read already leaves an
|
|
1061
|
+
// empty batch beside a real hole. The reconciled state is attached
|
|
1062
|
+
// either way; the sentence has to agree with it.
|
|
1063
|
+
if (d.loss) {
|
|
1064
|
+
return said(`Nothing new on ${id} that survived — there is a hole in the history here, and what ` +
|
|
1065
|
+
`was in it is gone. See lost for what is known about it, and windows_now and ` +
|
|
1066
|
+
`computer_now for where the computer actually stands, which is what the missing ` +
|
|
1067
|
+
`events would have told you.`, { ...body(id, d, sub.watching), ...extras });
|
|
1068
|
+
}
|
|
1069
|
+
return said(`Nothing new on ${id}. The stream is open and buffering, so this is an answer rather ` +
|
|
1070
|
+
`than a gap: nothing has been reported since you last read.`, { ...body(id, d, sub.watching), ...extras });
|
|
1071
|
+
}
|
|
1072
|
+
const kinds = [...new Set(d.events.map((e) => e.type))].join(', ');
|
|
1073
|
+
return said(`${d.events.length} event${d.events.length === 1 ? '' : 's'} on ${id}: ${kinds}.` +
|
|
1074
|
+
(d.more ? ` ${d.more} more are buffered — call again for them.` : ''), { ...body(id, d, sub.watching), ...extras });
|
|
1075
|
+
}));
|
|
1076
|
+
};
|
|
1077
|
+
//# sourceMappingURL=events.js.map
|