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.
Files changed (83) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +544 -0
  3. package/dist/api.d.ts +186 -0
  4. package/dist/api.d.ts.map +1 -0
  5. package/dist/api.js +932 -0
  6. package/dist/api.js.map +1 -0
  7. package/dist/cli.d.ts +55 -0
  8. package/dist/cli.d.ts.map +1 -0
  9. package/dist/cli.js +292 -0
  10. package/dist/cli.js.map +1 -0
  11. package/dist/errors.d.ts +560 -0
  12. package/dist/errors.d.ts.map +1 -0
  13. package/dist/errors.js +873 -0
  14. package/dist/errors.js.map +1 -0
  15. package/dist/events.d.ts +406 -0
  16. package/dist/events.d.ts.map +1 -0
  17. package/dist/events.js +1679 -0
  18. package/dist/events.js.map +1 -0
  19. package/dist/format.d.ts +125 -0
  20. package/dist/format.d.ts.map +1 -0
  21. package/dist/format.js +180 -0
  22. package/dist/format.js.map +1 -0
  23. package/dist/http.d.ts +46 -0
  24. package/dist/http.d.ts.map +1 -0
  25. package/dist/http.js +792 -0
  26. package/dist/http.js.map +1 -0
  27. package/dist/index.d.ts +13 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +12 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/paths.d.ts +394 -0
  32. package/dist/paths.d.ts.map +1 -0
  33. package/dist/paths.js +677 -0
  34. package/dist/paths.js.map +1 -0
  35. package/dist/server.d.ts +18 -0
  36. package/dist/server.d.ts.map +1 -0
  37. package/dist/server.js +97 -0
  38. package/dist/server.js.map +1 -0
  39. package/dist/session.d.ts +78 -0
  40. package/dist/session.d.ts.map +1 -0
  41. package/dist/session.js +166 -0
  42. package/dist/session.js.map +1 -0
  43. package/dist/stdio.d.ts +11 -0
  44. package/dist/stdio.d.ts.map +1 -0
  45. package/dist/stdio.js +43 -0
  46. package/dist/stdio.js.map +1 -0
  47. package/dist/tools/agent.d.ts +16 -0
  48. package/dist/tools/agent.d.ts.map +1 -0
  49. package/dist/tools/agent.js +147 -0
  50. package/dist/tools/agent.js.map +1 -0
  51. package/dist/tools/computers.d.ts +3 -0
  52. package/dist/tools/computers.d.ts.map +1 -0
  53. package/dist/tools/computers.js +1037 -0
  54. package/dist/tools/computers.js.map +1 -0
  55. package/dist/tools/events.d.ts +3 -0
  56. package/dist/tools/events.d.ts.map +1 -0
  57. package/dist/tools/events.js +1077 -0
  58. package/dist/tools/events.js.map +1 -0
  59. package/dist/tools/guest.d.ts +3 -0
  60. package/dist/tools/guest.d.ts.map +1 -0
  61. package/dist/tools/guest.js +761 -0
  62. package/dist/tools/guest.js.map +1 -0
  63. package/dist/tools/input.d.ts +3 -0
  64. package/dist/tools/input.d.ts.map +1 -0
  65. package/dist/tools/input.js +240 -0
  66. package/dist/tools/input.js.map +1 -0
  67. package/dist/tools/snapshots.d.ts +3 -0
  68. package/dist/tools/snapshots.d.ts.map +1 -0
  69. package/dist/tools/snapshots.js +333 -0
  70. package/dist/tools/snapshots.js.map +1 -0
  71. package/dist/tools/templates.d.ts +3 -0
  72. package/dist/tools/templates.d.ts.map +1 -0
  73. package/dist/tools/templates.js +492 -0
  74. package/dist/tools/templates.js.map +1 -0
  75. package/dist/tools/types.d.ts +18 -0
  76. package/dist/tools/types.d.ts.map +1 -0
  77. package/dist/tools/types.js +2 -0
  78. package/dist/tools/types.js.map +1 -0
  79. package/dist/tools/webhooks.d.ts +3 -0
  80. package/dist/tools/webhooks.d.ts.map +1 -0
  81. package/dist/tools/webhooks.js +260 -0
  82. package/dist/tools/webhooks.js.map +1 -0
  83. 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