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
package/dist/events.js ADDED
@@ -0,0 +1,1679 @@
1
+ /**
2
+ * What a computer is doing, held open on this side of the model.
3
+ *
4
+ * `GET computers/:id/events` (platform OPL-3785) is a websocket that says what
5
+ * a computer is doing without being asked, so that an agent stops paying for a
6
+ * screenshot to learn that nothing has changed. Every other client of that
7
+ * stream hands it to its caller as an iterator, because every other caller sits
8
+ * in a loop. A model does not: it takes turns, and between two turns there is
9
+ * nobody here to read a socket.
10
+ *
11
+ * So the socket lives HERE, in the session, and the model holds nothing but a
12
+ * cursor. One connection per computer, opened the first time a tool asks about
13
+ * it, kept across turns, reaped when nothing has asked for a while. What
14
+ * arrives while the model is thinking goes into a bounded ring, and the next
15
+ * `poll_events` or `wait_for_event` is handed it in order. The model never
16
+ * learns that a socket exists.
17
+ *
18
+ * That is the whole of OPL-3926's decision, and the alternative it rejects is
19
+ * worth naming: an MCP `resources/subscribe` and a `notifications/*` doorbell.
20
+ * The spec's notification filter is a closed vocabulary — `toolsListChanged`,
21
+ * `promptsListChanged`, `resourcesListChanged`, `resourceSubscriptions` — with
22
+ * no channel for a server's own domain events, so a `window.opened` could only
23
+ * ride in as `resources/updated` on some URI, and that notification carries a
24
+ * URI and no payload. The model would still have to read. Which makes the
25
+ * doorbell an addition to this file rather than a replacement for it, and one
26
+ * whose bell nothing on the other end rings yet.
27
+ *
28
+ * Written against the `events_url` entry in the platform's `web/lib/apidoc.ts`,
29
+ * which is the reference this must not contradict.
30
+ */
31
+ import { posix } from 'node:path';
32
+ import { WebSocket as UndiciWebSocket } from 'undici';
33
+ import { isTransientForPoll, MandalaError } from './errors.js';
34
+ import { unwrapComputer } from './format.js';
35
+ import * as P from './paths.js';
36
+ /**
37
+ * undici's `WebSocket`, not Node's global one.
38
+ *
39
+ * The same choice `api.ts` makes about `fetch` and for a plainer reason: this
40
+ * package already depends on undici, `engines` says Node 20.3 and the global
41
+ * `WebSocket` did not arrive unflagged until Node 22. A server that worked on
42
+ * the Node it claims to support for every tool except these two would be a
43
+ * worse failure than not shipping them, because it only appears at the moment
44
+ * somebody waits for something.
45
+ */
46
+ export const defaultEventSocket = (url) => new UndiciWebSocket(url);
47
+ /**
48
+ * The frames that are statements about the STREAM rather than about the
49
+ * computer.
50
+ *
51
+ * None of the three is delivered as an event here, which is the second half of
52
+ * OPL-3926's decision. A model handed a `gap` frame — a type it has no
53
+ * procedure for, on a client the reference cannot tell to "reconcile with a
54
+ * listing" — will invent a recovery procedure. So a gap is answered inline
55
+ * instead: see {@link Subscription.loss} and the state the tools attach to it.
56
+ */
57
+ const STREAM_FRAMES = new Set(['gap', 'closed', 'capabilities']);
58
+ /**
59
+ * How many trees ONE stream may nominate, which is the platform's own cap.
60
+ *
61
+ * There is a second, larger one this client cannot enforce: a computer watches
62
+ * at most 32 distinct trees across every stream open on it, and a nomination
63
+ * past that is refused on the upgrade — which reaches a websocket client as a
64
+ * socket that would not open and nothing else. See {@link Subscription.nominate}
65
+ * for what is done about that.
66
+ */
67
+ export const MAX_WATCHES = 4;
68
+ /**
69
+ * How many connections in a row may fail to open before a watch is blamed.
70
+ *
71
+ * A watch is the one thing on this URL a HOST can refuse: a path it will not
72
+ * honour is a `400` on the upgrade, and a nomination past the 32 trees a
73
+ * computer will watch is a `409`. Neither status nor body reaches a websocket
74
+ * client — undici reports both as "Received network error or non-101 status
75
+ * code" — so a refused watch is indistinguishable here from a host that is
76
+ * down, and the reconnect loop would ask for the same refused set forever.
77
+ *
78
+ * That is not a file watch failing. It is the WHOLE STREAM failing: the window
79
+ * events, the process exits and the readiness that were arriving before anybody
80
+ * asked about a directory all stop, and nothing ever says why. So after this
81
+ * many failures the newest nomination is shed and the stream is allowed back —
82
+ * newest because it is the one that has just changed, and the older ones were
83
+ * connecting a moment ago. Two rather than one, because a single failed
84
+ * connection is ordinary weather.
85
+ */
86
+ const WATCH_SHED_AFTER = 2;
87
+ /** The platform's bound on one nominated path, in BYTES rather than characters. */
88
+ export const MAX_WATCH_PATH_BYTES = 256;
89
+ /**
90
+ * A nominated path, in the spelling the host will accept — or a refusal saying
91
+ * why it will not.
92
+ *
93
+ * A mirror of the platform's `cleanWatchPath`, and mirrored rather than left to
94
+ * the server for one reason: a path the host refuses is a `400` on the UPGRADE,
95
+ * and an upgrade that fails reaches a websocket client as an error event with
96
+ * no status and no body. Undici says "Received network error or non-101 status
97
+ * code" and that is the whole of it. So a bad path sent optimistically is
98
+ * indistinguishable here from a host that is down, and the answer a model would
99
+ * get is a reconnect loop under "could not open the event stream" — for a
100
+ * mistake that is entirely visible before anything is sent.
101
+ *
102
+ * Normalising rather than refusing the shapes people actually type is the
103
+ * platform's choice and is kept: a trailing slash and a `.` segment name one
104
+ * directory unambiguously. What must not happen is normalising SILENTLY, since
105
+ * the cleaned form is what events carry and what a caller has to match on — so
106
+ * the tools say what a path became when it changed.
107
+ */
108
+ export function cleanWatchPath(input) {
109
+ if (!input)
110
+ throw new MandalaError('a watch path cannot be empty');
111
+ if (Buffer.byteLength(input, 'utf8') > MAX_WATCH_PATH_BYTES) {
112
+ throw new MandalaError(`a watch path may be at most ${MAX_WATCH_PATH_BYTES} bytes; that one is ` +
113
+ `${Buffer.byteLength(input, 'utf8')}`);
114
+ }
115
+ // A lone surrogate is a string JavaScript will hold and UTF-8 cannot carry.
116
+ // The round trip replaces one with U+FFFD, which is the cheapest way to ask
117
+ // "would this survive being sent" without hand-decoding the code units.
118
+ if (Buffer.from(input, 'utf8').toString('utf8') !== input) {
119
+ throw new MandalaError('a watch path must be valid UTF-8');
120
+ }
121
+ // Refused rather than escaped, as the platform refuses them: the value ends
122
+ // up in log lines and in an opening frame, so a newline in one is a caller
123
+ // choosing what somebody else's terminal renders.
124
+ for (const ch of input) {
125
+ const code = ch.codePointAt(0) ?? 0;
126
+ if (code < 0x20 || code === 0x7f) {
127
+ throw new MandalaError('a watch path cannot contain control characters');
128
+ }
129
+ }
130
+ if (!input.startsWith('/')) {
131
+ throw new MandalaError(`a watch path must be absolute, and ${JSON.stringify(input)} is not. Paths on this stream ` +
132
+ "are the guest's own, so there is no working directory here for a relative one to be " +
133
+ 'relative to.');
134
+ }
135
+ // Go's `path.Clean` and not Node's `normalize`, because what has to come out
136
+ // of here is the spelling the HOST will echo, and the two do not agree
137
+ // everywhere. Both differences are fixed up rather than relied on: normalize
138
+ // keeps a trailing slash and Clean does not, and a leading `//` is a shape
139
+ // POSIX leaves implementation-defined — Node collapses it on the versions
140
+ // this was measured on, but `engines` here says 20.3 and up and this is not
141
+ // something to discover from a `400` on an upgrade that reaches a websocket
142
+ // client with no status and no body. Clean collapses it; so does this.
143
+ let c = posix.normalize(input);
144
+ if (c.length > 1 && c.endsWith('/'))
145
+ c = c.slice(0, -1);
146
+ c = c.replace(/^\/{2,}/, '/');
147
+ if (c === '/') {
148
+ throw new MandalaError('watching / is not a nomination; name the directory you are waiting on. The root is every ' +
149
+ 'tree at once, which would spend the directory budget on /usr before reaching anything ' +
150
+ 'you care about and then report nothing but loss.');
151
+ }
152
+ return c;
153
+ }
154
+ /** How many events one computer may hold for a model that has not read them. */
155
+ export const MAX_BUFFERED = 1024;
156
+ /** How long a subscription nothing has asked about is kept open. */
157
+ export const IDLE_REAP_MS = 5 * 60_000;
158
+ /** How often the hub looks for one to reap. */
159
+ const SWEEP_MS = 60_000;
160
+ /** First backoff step after a failed connection, doubling to {@link MAX_BACKOFF_MS}. */
161
+ const BACKOFF_MS = 500;
162
+ const MAX_BACKOFF_MS = 15_000;
163
+ /** How long a connection has to reach its opening frame. */
164
+ const CONNECT_TIMEOUT_MS = 20_000;
165
+ /**
166
+ * One computer's event stream, held across turns.
167
+ *
168
+ * Single-consumer, like the session it belongs to. It keeps its own place in
169
+ * the platform's stream so that a socket which drops mid-turn resumes rather
170
+ * than restarts, and it keeps the model's place in the buffer so that a model
171
+ * which asks nothing for four turns is still handed what happened during them.
172
+ */
173
+ export class Subscription {
174
+ computerId;
175
+ #api;
176
+ #socketFor;
177
+ #ring = [];
178
+ /** The index the next arriving event will be given. Never reused. */
179
+ #nextIndex = 0;
180
+ /** The index after the last event handed to the model. */
181
+ #delivered = 0;
182
+ /** Set once the model has read anything at all, so a first read can say so. */
183
+ #read = false;
184
+ #loss;
185
+ /** The platform cursor to resume from: after the last event RECEIVED. */
186
+ #resume;
187
+ /**
188
+ * Where this stream began, as a platform cursor.
189
+ *
190
+ * The seeded `since`, or the first opening frame's own cursor. It is what a
191
+ * NEW subscription for this computer has to resume from when the model was
192
+ * never handed anything, so that a buffer thrown away by a reap is replayed
193
+ * rather than skipped.
194
+ */
195
+ #start;
196
+ /** The cursor after the last event actually handed to the model. */
197
+ #deliveredCursor;
198
+ #hello;
199
+ #types;
200
+ /**
201
+ * The trees nominated on this stream, oldest nomination first.
202
+ *
203
+ * Held here rather than on the socket because it OUTLIVES the socket: it is
204
+ * what every reconnect re-nominates, and a set that lived on the connection
205
+ * would be silently dropped by the first reconnect — leaving a model waiting
206
+ * on a tree nobody was watching any more, which is the one failure this whole
207
+ * feature is built to make impossible.
208
+ */
209
+ #watches = [];
210
+ /**
211
+ * What the host calls each nominated tree, when that is not what we called it.
212
+ *
213
+ * The reference says to match on what `hello` gives back rather than on what
214
+ * you sent, and this is that — kept as a MAP rather than by overwriting the
215
+ * nominations, which is the shape that reads naturally and is wrong. The
216
+ * nominations are what goes back on the URL at every reconnect, so replacing
217
+ * them with the host's spelling makes this client's idea of a tree drift one
218
+ * rename per connection; and if the two normalisations ever disagreed about a
219
+ * path, every call naming it would see a tree it had not nominated, reopen
220
+ * the socket, and be renamed again. One end has to be fixed, and it is the
221
+ * end that does the sending.
222
+ *
223
+ * Filled by position, which is exactly what the platform promises: it
224
+ * de-duplicates and preserves order, and this client never nominates a
225
+ * duplicate, so entry i of the echo is nomination i.
226
+ */
227
+ #hostName = new Map();
228
+ /** Whether each nominated tree is being watched YET. See {@link Watched}. */
229
+ #armed = new Map();
230
+ /**
231
+ * How many times each tree has come up, so a RE-arm can be told from the one
232
+ * a caller waited for.
233
+ *
234
+ * A second `armed` is not a duplicate to suppress: it says the watch was
235
+ * interrupted and is reporting from here, so anything that happened in
236
+ * between was never reported and the tree has to be re-read. A wait that
237
+ * matched only on file paths would sit through that and then say nothing
238
+ * changed.
239
+ */
240
+ #armGen = new Map();
241
+ /**
242
+ * The STANDING loss on each tree, cleared when it arms.
243
+ *
244
+ * Standing is the whole of what this holds, and it is why a `flood` is not in
245
+ * it. A flood is a burst: the tree changed faster than the cap allows it to be
246
+ * reported, and once the burst is over the tree is being reported normally
247
+ * again — so remembering one would make every later answer about a tree that
248
+ * saw one build hedge forever. `budget` and `unwatchable` are conditions
249
+ * rather than moments: part of the tree is not being watched, or none of it
250
+ * is, and both stay true until something changes them.
251
+ */
252
+ #watchLost = new Map();
253
+ /**
254
+ * Trees this subscription inherited from one that went away.
255
+ *
256
+ * A tree is watched by the CONNECTION, so the reap that took the previous
257
+ * subscription also stopped the guest watching — and inotify reports changes
258
+ * and not state, so nothing that happened in between was recorded anywhere for
259
+ * a replay to hand back. Re-nominating gets the watch going again and says
260
+ * nothing about the hole, which would leave a model reading a perfectly
261
+ * ordinary "nothing changed" over minutes during which nothing was looking.
262
+ * Said once, on the first answer about the tree, and then forgotten.
263
+ */
264
+ #interrupted = new Set();
265
+ /**
266
+ * Trees a connection carrying them would not open, PROVEN so.
267
+ *
268
+ * Proven means the experiment came back positive — see {@link #cleared}: the
269
+ * tree was withheld, the same stream opened without it, and there is nothing
270
+ * else the difference could be. A tree merely suspected is
271
+ * {@link #shedCandidate} and is not in here.
272
+ */
273
+ #watchRefused = new Set();
274
+ /**
275
+ * The tree currently withheld from the URL to find out whether it is the
276
+ * reason nothing will open. `undefined` when no experiment is running.
277
+ *
278
+ * It stays in {@link #watches} throughout, which is what keeps a caller from
279
+ * being told its tree was evicted to make room for somebody else's — this is
280
+ * a suspicion, not a decision about what the caller asked for.
281
+ */
282
+ #shedCandidate;
283
+ /**
284
+ * Whether a watch has been ruled out as the reason connections are failing.
285
+ *
286
+ * Set when an experiment comes back negative, cleared the moment anything
287
+ * greets. Without it a host that is simply DOWN would shed its way through
288
+ * every tree in the set two failures at a time, reporting each in turn as one
289
+ * the host would not carry.
290
+ */
291
+ #shedRuledOut = false;
292
+ /** What the last connection that reached an opening frame was carrying. */
293
+ #lastGood = [];
294
+ /** Consecutive connections that never reached an opening frame while watching. */
295
+ #upgradeFailures = 0;
296
+ /**
297
+ * Whether the host echoed `watching` for a nomination this stream made.
298
+ *
299
+ * `undefined` until an opening frame has been seen with something nominated.
300
+ * A host that predates `file.changed` ignores `&watch=` rather than refusing
301
+ * it, so the socket opens, nothing is watched, and no event ever arrives —
302
+ * a silence indistinguishable from a quiet directory. This is how that is
303
+ * told apart, and it is the only way: the frame is the whole of the answer.
304
+ */
305
+ #watchingEchoed;
306
+ /**
307
+ * The trees the connection now open put on its URL, and whether it has
308
+ * greeted.
309
+ *
310
+ * Two fields for one question — "is the tree I just nominated actually on the
311
+ * wire" — and the question has to be asked that way rather than by counting
312
+ * opening frames. A nomination can land while a connection is IN FLIGHT: the
313
+ * socket dropped, the reconnect has already built its URL from the old watch
314
+ * set, and its `hello` is still coming. Counting frames, that frame answers
315
+ * the nomination — and it is a frame from a connection that never carried it,
316
+ * so its missing `watching` reads as a host that ignores watches and the
317
+ * caller is refused for a reason that is not true.
318
+ */
319
+ #sent = [];
320
+ #greeted = false;
321
+ /**
322
+ * Whether the socket now closing was closed by THIS side to re-nominate.
323
+ *
324
+ * A watch set is a connection parameter, so adding one means opening a new
325
+ * connection. The reconnect floor in {@link #loop} exists to stop a host that
326
+ * sends `hello` and closes from becoming a spin loop; a reconnect this side
327
+ * asked for is not that, and paying half a second for one would be half a
328
+ * second added to every first watch on a tree.
329
+ */
330
+ #renominate = false;
331
+ #state = { status: 'connecting' };
332
+ #wake = new Set();
333
+ #socket;
334
+ #abort = new AbortController();
335
+ #lastUsed = Date.now();
336
+ #running = false;
337
+ constructor(api, computerId, socketFor, since, watches = []) {
338
+ // Never `extra.signal`. A subscription outlives the tool call that opened
339
+ // it by design, and binding it to that call's signal would close the socket
340
+ // the moment the turn that opened it ended — which is every turn.
341
+ this.#api = api;
342
+ this.computerId = computerId;
343
+ this.#socketFor = socketFor;
344
+ // Where a previous subscription for this computer had got to, if the hub
345
+ // still remembers one. The first connection then asks the platform to
346
+ // replay from there rather than joining at the head — see
347
+ // {@link EventHub.open}, which is where that memory lives.
348
+ this.#resume = since;
349
+ this.#start = since;
350
+ // And what it was watching, for the same reason and with a sharper edge.
351
+ // A tree stops being watched when the socket carrying it closes, so a reap
352
+ // silently ends every watch on that computer — and the next answer about
353
+ // one would be an honest-looking "nothing changed" over a window during
354
+ // which nothing was watching, which is the one thing a file watch must
355
+ // never produce. Nominating them again on the first connection is what
356
+ // makes the tool's promise that a nomination lasts across turns true.
357
+ this.#watches = watches.slice(0, MAX_WATCHES);
358
+ for (const w of this.#watches)
359
+ this.#interrupted.add(w);
360
+ }
361
+ /** The trees this stream nominates, in this client's own spelling. */
362
+ get nominations() {
363
+ return [...this.#watches];
364
+ }
365
+ /**
366
+ * Where a REPLACEMENT for this subscription should start.
367
+ *
368
+ * The position after the last event the MODEL was handed — deliberately not
369
+ * `#resume`, which is after the last event this SOCKET received. The two are
370
+ * the same only when the model is caught up, and the case where they differ
371
+ * is the case this exists for: events arrive, the model is busy on another
372
+ * computer, the idle sweep reaps the subscription and its ring goes with it.
373
+ * Handing the replacement `#resume` there would ask the platform to replay
374
+ * from AFTER the unread events — losing exactly what the memory was meant to
375
+ * preserve, and losing it silently, since a `since` the platform can honour
376
+ * produces no gap.
377
+ *
378
+ * Before anything has been delivered the answer is where this stream began,
379
+ * which replays the whole buffer.
380
+ */
381
+ get resumeCursor() {
382
+ return this.#deliveredCursor ?? this.#start;
383
+ }
384
+ get state() {
385
+ return this.#state;
386
+ }
387
+ get idleMs() {
388
+ return Date.now() - this.#lastUsed;
389
+ }
390
+ /** What this computer can emit, as last stated. `undefined` before `hello`. */
391
+ get eventTypes() {
392
+ return this.#types ? [...this.#types] : undefined;
393
+ }
394
+ /**
395
+ * The trees nominated on this stream, and whether each is live yet.
396
+ *
397
+ * In the HOST's spelling, because that is the one every event carries and so
398
+ * the one a reader of this has to be able to match on.
399
+ */
400
+ get watching() {
401
+ return this.#watches.map((path) => ({
402
+ path: this.hostPath(path),
403
+ armed: this.#armed.get(path) === true,
404
+ }));
405
+ }
406
+ /** What the host calls a tree this stream nominated. Its own name until it says. */
407
+ hostPath(nominated) {
408
+ return this.#hostName.get(nominated) ?? nominated;
409
+ }
410
+ /** Whether this tree is being watched right now. */
411
+ isArmed(path) {
412
+ return this.#armed.get(path) === true;
413
+ }
414
+ /**
415
+ * Which arming of this tree is current.
416
+ *
417
+ * Compared rather than read: a caller holds the number from when it started
418
+ * waiting and asks whether it still holds, which is how a re-arm ends a wait
419
+ * that was looking for file paths.
420
+ */
421
+ armGeneration(path) {
422
+ return this.#armGen.get(path) ?? 0;
423
+ }
424
+ /** The last thing this tree said it had lost, if it has said one since arming. */
425
+ lostFor(path) {
426
+ return this.#watchLost.get(path);
427
+ }
428
+ /**
429
+ * Whether this host honoured the nomination at all.
430
+ *
431
+ * `undefined` while nothing has been nominated or no opening frame has been
432
+ * seen since. `false` is a host with no `file.changed` — see
433
+ * {@link #watchingEchoed}.
434
+ */
435
+ get watchesHonoured() {
436
+ return this.#watchingEchoed;
437
+ }
438
+ /** Whether the connection now open nominated this tree and has greeted. */
439
+ nominationLive(path) {
440
+ return this.#greeted && this.#sent.includes(path);
441
+ }
442
+ /**
443
+ * Whether this tree is being watched RIGHT NOW, connection included.
444
+ *
445
+ * The question {@link isArmed} does not answer and the one a quiet answer has
446
+ * to be built on. `#armed` is what the last opening frame said, and it is
447
+ * deliberately not cleared when a socket dies — clearing it would make every
448
+ * routine reconnect look like a re-arm, which is a "go and re-read the tree"
449
+ * for an interruption the platform's own replay covered. But a tree on a
450
+ * connection that is not up is not being watched at this instant, and a tool
451
+ * that said "watched for the whole of that and still is" while reconnecting
452
+ * would be wrong about the half of that sentence it can actually check.
453
+ */
454
+ watchLive(path) {
455
+ return this.nominationLive(path) && this.isArmed(path);
456
+ }
457
+ /** Whether this stream is still nominating this tree at all. */
458
+ nominates(path) {
459
+ return this.#watches.includes(path);
460
+ }
461
+ /** Whether the connection carrying this tree was refused. See {@link WATCH_SHED_AFTER}. */
462
+ watchWasRefused(path) {
463
+ return this.#watchRefused.has(path);
464
+ }
465
+ /**
466
+ * Whether this tree went unwatched between a previous subscription and this
467
+ * one, and has not been told about it yet. Reading it CLEARS it: it is a
468
+ * thing to say once.
469
+ */
470
+ takeInterruption(path) {
471
+ return this.#interrupted.delete(path);
472
+ }
473
+ /** Mark it in use, so the idle sweep leaves it alone. */
474
+ touch() {
475
+ this.#lastUsed = Date.now();
476
+ }
477
+ /**
478
+ * Ask this stream to report file changes under a tree.
479
+ *
480
+ * The one thing on this socket that has to be ASKED for: without a nomination
481
+ * no `file.changed` can arrive at all, which makes a watch a connection
482
+ * parameter rather than an event type to add to a list. So nominating
483
+ * something new reopens the connection — with this subscription's own
484
+ * `since`, so the reconnect resumes rather than restarts and nothing on the
485
+ * stream is missed by it.
486
+ *
487
+ * Already-nominated is a no-op that moves the tree to the front of the queue,
488
+ * and that ordering is what {@link MAX_WATCHES} costs: a fifth tree evicts the
489
+ * one nobody has asked about for longest. Evicting rather than refusing is
490
+ * deliberate — a model that has moved on to a different directory should not
491
+ * have to know that four earlier ones are in the way — but it is never
492
+ * SILENT, because a dropped watch is a tree that stops reporting. The caller
493
+ * is handed what went and says so.
494
+ *
495
+ * It says only what it EVICTED, and not whether it reopened anything. The
496
+ * caller has a sharper question than "did this change something" —
497
+ * {@link nominationLive}, which asks whether the open connection is carrying
498
+ * the tree — and the two differ in the case that matters: a nomination this
499
+ * call did not change can still be off the wire, because the connection
500
+ * carrying it dropped a moment ago.
501
+ */
502
+ nominate(path) {
503
+ this.touch();
504
+ // A nomination is also a RETRY, and this is where that is honoured. Whatever
505
+ // this stream concluded about this path last time — that its connection
506
+ // would not open, that it is the one under suspicion for that — is a fact
507
+ // about a moment that has passed: the computer may since have dropped below
508
+ // its tree limit, and the directory may since exist. "Call again" is the
509
+ // advice every one of those refusals gives, so calling again has to mean
510
+ // something.
511
+ const retrying = this.#watchRefused.delete(path) || this.#shedCandidate === path;
512
+ if (this.#shedCandidate === path)
513
+ this.#shedCandidate = undefined;
514
+ this.#upgradeFailures = 0;
515
+ this.#shedRuledOut = false;
516
+ const at = this.#watches.indexOf(path);
517
+ if (at >= 0) {
518
+ // Most recently asked about goes last, so the eviction below always takes
519
+ // the tree that has waited longest for somebody to care about it. Which
520
+ // tree gets BLAMED for a stream that will not open is a different order
521
+ // and deliberately not this one — see `#blamed`.
522
+ this.#watches.splice(at, 1);
523
+ this.#watches.push(path);
524
+ // Already on the wire and not under suspicion, so there is nothing to
525
+ // reopen for. A tree being retried is neither.
526
+ if (!retrying && this.#sent.includes(path))
527
+ return {};
528
+ this.#renominate = true;
529
+ this.#socket?.close();
530
+ this.#wakeAll();
531
+ return {};
532
+ }
533
+ this.#watches.push(path);
534
+ let evicted;
535
+ if (this.#watches.length > MAX_WATCHES) {
536
+ evicted = this.#watches.shift();
537
+ if (evicted !== undefined) {
538
+ this.#armed.delete(evicted);
539
+ this.#watchLost.delete(evicted);
540
+ this.#hostName.delete(evicted);
541
+ this.#interrupted.delete(evicted);
542
+ this.#watchRefused.delete(evicted);
543
+ // An experiment about a tree nobody nominates any more has nothing left
544
+ // to prove, and letting it finish would file a refusal against a path
545
+ // this stream is no longer asking for.
546
+ if (this.#shedCandidate === evicted)
547
+ this.#shedCandidate = undefined;
548
+ // The arm generation is deliberately NOT deleted. It has to stay
549
+ // monotonic per path, because a waiter parked on this tree is holding a
550
+ // number from before the eviction: reset to zero and re-nominated, the
551
+ // tree would come back at one and that waiter would read an eviction as
552
+ // a re-arm — "reporting starts here, re-read the tree" about a tree that
553
+ // had simply been taken away from it. Eviction is told by membership,
554
+ // which is what `nominates` is for.
555
+ }
556
+ }
557
+ // Closed rather than aborted: `#loop` is still running and its next turn
558
+ // reads `#watches` for the new connection. The flag is what keeps that turn
559
+ // from paying the reconnect floor for a reconnection this side chose.
560
+ this.#renominate = true;
561
+ this.#socket?.close();
562
+ this.#wakeAll();
563
+ return { evicted };
564
+ }
565
+ /** Open the socket, if it is not already open. Returns at once. */
566
+ start() {
567
+ if (this.#running)
568
+ return;
569
+ this.#running = true;
570
+ void this.#run();
571
+ }
572
+ /** Close the socket and stop reconnecting. The buffer goes with it. */
573
+ close(reason = 'the subscription was closed') {
574
+ this.#abort.abort();
575
+ this.#state = { status: 'stopped', reason };
576
+ this.#socket?.close();
577
+ this.#socket = undefined;
578
+ this.#wakeAll();
579
+ }
580
+ /** The index of the oldest event still buffered. */
581
+ get #oldest() {
582
+ return this.#ring.length ? this.#ring[0].index : this.#nextIndex;
583
+ }
584
+ /**
585
+ * Everything the model has not been handed, oldest first.
586
+ *
587
+ * `since` is an override rather than the ordinary way in. A model that passes
588
+ * nothing gets what it has not seen, which is what "what happened while I was
589
+ * thinking" means and is the call that cannot be got wrong; a model that
590
+ * keeps a cursor can rewind or resume with one. Both are the same position in
591
+ * the end — this class holds it either way.
592
+ */
593
+ read(opts = { limit: 100 }) {
594
+ this.touch();
595
+ const attached = !this.#read;
596
+ this.#read = true;
597
+ const from = this.resolveFrom(opts.since);
598
+ const end = opts.through !== undefined ? opts.through + 1 : this.#nextIndex;
599
+ const window = this.#ring.filter((b) => b.index >= from && b.index < end);
600
+ // The OLDEST `limit`, not the newest, and the position advances only over
601
+ // what is actually returned — so a batch that does not fit leaves the rest
602
+ // buffered for the next call rather than dropping it. `through` is the one
603
+ // exception: a wait has already promised to return the event it matched, so
604
+ // it keeps the tail and says how much of the head it had to leave behind.
605
+ let batch = window;
606
+ let omitted = 0;
607
+ if (window.length > opts.limit) {
608
+ if (opts.through !== undefined) {
609
+ batch = window.slice(window.length - opts.limit);
610
+ omitted = window.length - opts.limit;
611
+ }
612
+ else {
613
+ batch = window.slice(0, opts.limit);
614
+ }
615
+ }
616
+ const last = batch.length ? batch[batch.length - 1] : undefined;
617
+ if (last && last.index + 1 > this.#delivered) {
618
+ const at = last.event.cursor;
619
+ if (typeof at === 'string' && at)
620
+ this.#deliveredCursor = at;
621
+ }
622
+ this.#delivered = Math.max(this.#delivered, last ? last.index + 1 : from);
623
+ const loss = omitted
624
+ ? {
625
+ // An unknown count plus a known one is still unknown. Adding the two
626
+ // would report a precise number for a hole nobody can measure, which
627
+ // is the one thing a loss report must not do.
628
+ events: this.#loss?.events === null ? null : (this.#loss?.events ?? 0) + omitted,
629
+ reason: this.#loss
630
+ ? `${this.#loss.reason}; and ${omitted} more than limit allowed were stepped over to reach the event you waited for`
631
+ : `${omitted} events older than the one you waited for did not fit in limit and were stepped over`,
632
+ }
633
+ : this.#loss;
634
+ this.#loss = undefined;
635
+ return {
636
+ events: batch.map((b) => b.event),
637
+ cursor: this.#position(last),
638
+ more: Math.max(0, this.#nextIndex - this.#delivered),
639
+ loss,
640
+ hello: this.#hello,
641
+ attached,
642
+ };
643
+ }
644
+ /**
645
+ * Where a read or a wait starts: the model's own place, or the cursor it named.
646
+ *
647
+ * A cursor this buffer cannot place is not an error and not silence. It may
648
+ * be from before the socket opened, from before a reap, or simply not one of
649
+ * ours; either way the events between there and here are not something this
650
+ * server holds, and saying so is the whole of the gap discipline — never a
651
+ * frame the model has to interpret, always a sentence and, from the tool, the
652
+ * state it would otherwise have gone to reconcile against.
653
+ */
654
+ resolveFrom(since) {
655
+ // Where the model is, which is not where the socket is: it may be four
656
+ // turns behind, and everything between the two is exactly what it has not
657
+ // been handed yet.
658
+ if (since === undefined)
659
+ return Math.max(this.#delivered, this.#oldest);
660
+ const at = this.#ring.findIndex((b) => b.event.cursor === since);
661
+ if (at >= 0)
662
+ return this.#ring[at].index + 1;
663
+ // The position at the moment this connection attached, which is
664
+ // legitimately older than anything in the ring on a quiet computer. The one
665
+ // deliberate rewind: a caller asking for it is asking to be re-sent this
666
+ // connection's whole buffer, and saying so exactly.
667
+ if (since === this.#hello?.cursor)
668
+ return this.#oldest;
669
+ // Otherwise: the unread frontier, NOT the oldest thing still in the ring.
670
+ // A delivered event stays in the ring until the cap evicts it, so answering
671
+ // an unplaceable cursor with `#oldest` re-sent events the model already
672
+ // had — while attaching a loss note that said they "were not kept", which
673
+ // was false about exactly the events being re-sent.
674
+ this.#loss ??= {
675
+ events: null,
676
+ reason: 'that cursor is not a place this session can find, so whatever happened between it and ' +
677
+ 'the events below was not kept here',
678
+ };
679
+ return Math.max(this.#delivered, this.#oldest);
680
+ }
681
+ /**
682
+ * Wait for an event this predicate accepts, or for the deadline.
683
+ *
684
+ * Resolves with the matching event's index, or `undefined` for a wait that
685
+ * ended without one — a deadline, a caller who hung up, or a stream that
686
+ * stopped. None of those three is an error here and the caller says which.
687
+ */
688
+ async waitFor(matches, deadline, cancel, since, abandon) {
689
+ this.touch();
690
+ const start = this.resolveFrom(since);
691
+ for (;;) {
692
+ // Clamped to what is still buffered on every turn, because the ring can
693
+ // evict underneath a long wait. An event that arrived two turns ago and
694
+ // has not been read still satisfies a wait for it — a wait that only ever
695
+ // looked forward would hang on something that had already happened, which
696
+ // is the defect `hello.ready` exists to prevent one level down.
697
+ const from = Math.max(start, this.#oldest);
698
+ const hit = this.#ring.find((b) => b.index >= from && matches(b.event));
699
+ if (hit)
700
+ return hit.index;
701
+ if (deadline.aborted || cancel?.aborted)
702
+ return undefined;
703
+ if (this.#state.status === 'stopped')
704
+ return undefined;
705
+ // Asked on every wake, AFTER the buffered check, so an event that is
706
+ // already here still wins. This is what makes a `capabilities` frame
707
+ // arriving mid-wait answerable: the frame wakes every parked waiter but
708
+ // is not itself an event, so a loop that only re-ran `matches` saw
709
+ // nothing, parked again, and sat out the whole deadline on a computer
710
+ // that could no longer produce what it was waiting for.
711
+ if (abandon?.())
712
+ return undefined;
713
+ await this.#park(deadline, cancel);
714
+ }
715
+ }
716
+ /**
717
+ * Wait until this subscription has an opening frame, or has stopped.
718
+ *
719
+ * Separate from {@link waitFor} rather than expressed as a predicate over it,
720
+ * and the reason is worth keeping: `waitFor` evaluates its predicate against
721
+ * BUFFERED EVENTS, so a condition that is not about an event — "has hello
722
+ * landed" — is never evaluated at all on a computer where nothing is
723
+ * happening. Written that way it does not answer late, it answers at the
724
+ * deadline, on the one call where the deadline is twenty seconds.
725
+ */
726
+ async attached(deadline, cancel) {
727
+ while (!this.#types && this.#state.status !== 'stopped') {
728
+ if (deadline.aborted || cancel?.aborted)
729
+ return;
730
+ await this.#park(deadline, cancel);
731
+ }
732
+ }
733
+ /**
734
+ * Wait until the open connection is carrying this tree and has greeted.
735
+ *
736
+ * The counterpart to {@link attached} for a nomination, and separate for the
737
+ * reason the two of them are separate from {@link waitFor}: what is being
738
+ * waited for is not an event, so a predicate over the ring would never be
739
+ * evaluated on a computer where nothing is happening. `attached` cannot serve
740
+ * here either — it is satisfied the moment `#types` is set, which a
741
+ * nomination does not clear, so it would return at once and leave the caller
742
+ * reading the PREVIOUS connection's `watching`.
743
+ */
744
+ async nominated(path, deadline, cancel) {
745
+ while (!this.nominationLive(path) && this.#state.status !== 'stopped') {
746
+ if (deadline.aborted || cancel?.aborted)
747
+ return;
748
+ if (this.#settledAgainst(path))
749
+ return;
750
+ await this.#park(deadline, cancel);
751
+ }
752
+ }
753
+ /**
754
+ * Whether waiting for this tree to come live has stopped being worth doing.
755
+ *
756
+ * The same four questions the change wait asks on every wake, asked by the
757
+ * two waits that come BEFORE it — which used to park through all of them and
758
+ * then answer "it is not being watched yet, the nomination stands, call
759
+ * again". Every word of that is false when the tree has been evicted by
760
+ * another call, given up on as one this host will not carry, or when the
761
+ * computer has stopped being able to report file changes at all. Each of
762
+ * those is an answer this server already has, and parking on it turns an
763
+ * answer into a timeout the caller has to interpret.
764
+ */
765
+ #settledAgainst(path) {
766
+ if (this.#watchRefused.has(path))
767
+ return true;
768
+ if (!this.#watches.includes(path))
769
+ return true;
770
+ const types = this.#types;
771
+ if (types?.length && !types.includes('file.changed'))
772
+ return true;
773
+ return false;
774
+ }
775
+ /**
776
+ * Wait until this tree is actually being watched.
777
+ *
778
+ * The whole reason a file watch needs its own wait. `hello` accepting a
779
+ * nomination is not the tree being watched: the guest has to be asked, and on
780
+ * a computer nobody has opened a terminal on the host installs the watcher
781
+ * into the guest first — seconds, not milliseconds. inotify reports changes
782
+ * and not state, so nothing that happens in that window is ever reported.
783
+ * Returning before this is what makes a tool say "nothing changed" about a
784
+ * window during which nothing was watching, which is the one sentence a
785
+ * server whose whole promise is that it was listening must not say.
786
+ *
787
+ * Returns on the deadline, on a stopped stream, and on an `unwatchable` —
788
+ * which is the one `lost` that means the tree is not being watched at all,
789
+ * and so is not something more waiting will fix. The caller reads
790
+ * {@link isArmed} to find out which it got.
791
+ */
792
+ async armedWait(path, deadline, cancel) {
793
+ while (!this.isArmed(path) && this.#state.status !== 'stopped') {
794
+ if (deadline.aborted || cancel?.aborted)
795
+ return;
796
+ if (this.#watchLost.get(path) === 'unwatchable')
797
+ return;
798
+ if (this.#settledAgainst(path))
799
+ return;
800
+ await this.#park(deadline, cancel);
801
+ }
802
+ }
803
+ /** Sleep until something changes here, or until either signal fires. */
804
+ #park(deadline, cancel) {
805
+ return new Promise((resolve) => {
806
+ const done = () => {
807
+ this.#wake.delete(done);
808
+ deadline.removeEventListener('abort', done);
809
+ cancel?.removeEventListener('abort', done);
810
+ resolve();
811
+ };
812
+ this.#wake.add(done);
813
+ deadline.addEventListener('abort', done, { once: true });
814
+ cancel?.addEventListener('abort', done, { once: true });
815
+ });
816
+ }
817
+ /** The cursor for a position, or the connection's own when nothing was read. */
818
+ #position(last) {
819
+ const cursor = last?.event.cursor;
820
+ if (typeof cursor === 'string' && cursor)
821
+ return cursor;
822
+ return this.#resume ?? this.#hello?.cursor ?? '';
823
+ }
824
+ #wakeAll() {
825
+ for (const wake of [...this.#wake])
826
+ wake();
827
+ }
828
+ #push(event) {
829
+ this.#ring.push({ index: this.#nextIndex++, event });
830
+ if (this.#ring.length > MAX_BUFFERED) {
831
+ const evicted = this.#ring.splice(0, this.#ring.length - MAX_BUFFERED);
832
+ // Only what the model had not been handed is a loss. Dropping events it
833
+ // already read is the ring doing its job, and counting those would report
834
+ // a hole where there is none.
835
+ const unread = evicted.filter((b) => b.index >= this.#delivered).length;
836
+ if (unread) {
837
+ const before = this.#loss?.events;
838
+ this.#loss = {
839
+ events: before === null ? null : (before ?? 0) + unread,
840
+ reason: `more than ${MAX_BUFFERED} events went unread on this computer, so the oldest were ` +
841
+ 'dropped here rather than by the platform',
842
+ };
843
+ }
844
+ }
845
+ this.#wakeAll();
846
+ }
847
+ /**
848
+ * Connect, read, reconnect. Runs until {@link close} or until something says
849
+ * that reopening cannot help.
850
+ */
851
+ async #run() {
852
+ try {
853
+ await this.#loop();
854
+ }
855
+ catch (err) {
856
+ // Nothing awaits this loop, so anything escaping it would be an
857
+ // unhandled rejection and a stream that had silently stopped. Both are
858
+ // worse than the sentence.
859
+ this.#state = {
860
+ status: 'stopped',
861
+ reason: `the event stream failed: ${err instanceof Error ? err.message : String(err)}`,
862
+ };
863
+ this.#wakeAll();
864
+ }
865
+ }
866
+ async #loop() {
867
+ let backoff = BACKOFF_MS;
868
+ while (!this.#abort.signal.aborted) {
869
+ let url;
870
+ try {
871
+ url = await this.#url();
872
+ }
873
+ catch (err) {
874
+ if (err instanceof SettledError) {
875
+ this.#state = { status: 'stopped', reason: err.message };
876
+ this.#wakeAll();
877
+ return;
878
+ }
879
+ this.#state = { status: 'connecting' };
880
+ await sleep(backoff, this.#abort.signal);
881
+ backoff = Math.min(backoff * 2, MAX_BACKOFF_MS);
882
+ continue;
883
+ }
884
+ // Checked HERE, between the read and the socket, because `#url()` is a
885
+ // network round trip and a close can land inside it — a session ending, a
886
+ // computer deleted, an idle drop. Without this the socket opened after
887
+ // the abort is one nothing holds a reference to and nothing will ever
888
+ // close: `close()` already ran, `#socket` was undefined at the time, and
889
+ // an `abort` listener added to a signal that has ALREADY fired never
890
+ // fires. It would sit open and buffering into an object nobody can read,
891
+ // for as long as the process lives.
892
+ if (this.#abort.signal.aborted)
893
+ return;
894
+ const reached = await this.#connection(url);
895
+ if (this.#abort.signal.aborted)
896
+ return;
897
+ // A reconnection THIS side asked for, to put a new watch set on the URL.
898
+ // ASKED FIRST, before anything reads `reached`, because a connection this
899
+ // client closed did not fail: `nominate` closes the socket, and if it does
900
+ // so before the opening frame lands then `reached` is false for a reason
901
+ // that has nothing to do with the host. Counted as a failure it is
902
+ // evidence against a watch — and two of them, which is one re-nomination
903
+ // of an in-flight connection plus one ordinary blip, used to shed a tree
904
+ // and report it as one the host would not carry.
905
+ //
906
+ // It also skips the floor below, which exists for a host that keeps
907
+ // hanging up on us rather than for a connection we closed on purpose;
908
+ // paying it would put half a second in front of every first watch of a
909
+ // tree, under an arming the caller is already waiting on.
910
+ if (this.#renominate) {
911
+ this.#renominate = false;
912
+ continue;
913
+ }
914
+ // A connection that got as far as its opening frame is not a failure,
915
+ // however soon it died: a computer that restarts drops the socket every
916
+ // time, and treating that as a failed attempt would back a healthy
917
+ // stream off to fifteen seconds for doing what it always does.
918
+ backoff = reached ? BACKOFF_MS : Math.min(backoff * 2, MAX_BACKOFF_MS);
919
+ // Only the failing half is driven from here. A connection that WORKED is
920
+ // not a connection that has ended, and this line runs when one ends —
921
+ // which for a healthy stream is minutes or hours later, or never. The
922
+ // other half is in `#onHello`, where reaching the opening frame actually
923
+ // happens.
924
+ if (!reached)
925
+ this.#blamed();
926
+ // A floor even on a connection that worked. A host that sends `hello`
927
+ // and closes — which is what a subscriber being put down for not reading
928
+ // looks like from here — would otherwise be a reconnect loop with no
929
+ // interval at all, one `GET computers/:id` per turn of the event loop.
930
+ if (!this.#abort.signal.aborted) {
931
+ await sleep(reached ? BACKOFF_MS : backoff, this.#abort.signal);
932
+ }
933
+ }
934
+ }
935
+ /**
936
+ * A fresh `events_url`, on every connection and every reconnect.
937
+ *
938
+ * Re-read rather than cached, because the credential in it is rotated by a
939
+ * restart — and a restart is one of the ordinary reasons the socket dropped
940
+ * in the first place. A reconnect over the old URL is a 401 that arrives as a
941
+ * socket which closes with no status and no body, which is the least
942
+ * debuggable failure this file can produce.
943
+ *
944
+ * The read is also what answers the two refusals a websocket cannot report.
945
+ * The reference says a suspended computer is refused with `409` and
946
+ * `resume_required`, and a stopped one with `409 unavailable`; neither status
947
+ * nor body reaches a `WebSocket` client, so every client of this stream has
948
+ * to infer them. Here they are not inferred at all — the status is on the
949
+ * record this call already had to make for the URL.
950
+ */
951
+ async #url() {
952
+ let c;
953
+ try {
954
+ // Bound to this subscription's own signal. Without it a `closeAll()` on
955
+ // session teardown leaves this fetch running to undici's 330-second
956
+ // header timeout, with `#loop` parked inside it the whole time.
957
+ c = unwrapComputer(await this.#api.with(this.#abort.signal).json('GET', P.computer(this.computerId)));
958
+ }
959
+ catch (err) {
960
+ // A question already answered ends the stream rather than being asked
961
+ // again behind it: a deleted computer or a revoked key is otherwise a
962
+ // reconnect loop with nothing to stop it, asking forever and never
963
+ // saying the answer out loud.
964
+ if (err instanceof Error && !isTransientForPoll(err))
965
+ throw new SettledError(err.message);
966
+ throw err;
967
+ }
968
+ const status = c.status ?? 'unknown';
969
+ if (status === 'suspended') {
970
+ throw new SettledError(`${this.computerId} suspended, and the event stream is the one part of this API that does ` +
971
+ 'not resume a computer for you. Listening is not using, so a computer nobody touches ' +
972
+ 'suspends underneath its own stream. start_computer, then ask again.');
973
+ }
974
+ if (status === 'stopped' || status === 'build-failed') {
975
+ throw new SettledError(`${this.computerId} is ${status}, and only a running computer has an event stream. ` +
976
+ 'start_computer, then ask again.');
977
+ }
978
+ if (status !== 'running') {
979
+ // `starting`, `moving`, `creating` — states that clear on their own, so
980
+ // they get the backoff rather than the refusal. Settling on everything
981
+ // that was not `running` broke the flow the README advertises: a
982
+ // create_computer followed at once by wait_for_event("computer.ready")
983
+ // meets `starting`, which is the ordinary weather of a machine coming up
984
+ // and is precisely what the caller is waiting through.
985
+ throw new MandalaError(`${this.computerId} is ${status}; waiting for it to be running`);
986
+ }
987
+ const vnc = c.vnc;
988
+ const url = typeof vnc?.events_url === 'string' ? vnc.events_url : undefined;
989
+ if (url) {
990
+ // Parsed HERE, where every other events_url decision is made, rather than
991
+ // beside the socket. A string that is not a URL is the same fact as the
992
+ // missing one below — there is nothing to connect to — and this method is
993
+ // the one place that says so with a sentence instead of an exception.
994
+ //
995
+ // It used to throw from inside the connection's Promise executor, which
996
+ // REJECTED: nothing between there and `#run` catches, so the subscription
997
+ // reached a terminal `stopped` reading "the event stream failed: Invalid
998
+ // URL". Settling it is the same outcome said properly. Retrying it
999
+ // forever would be worse than either — the URL is re-read on every
1000
+ // attempt, so a value the platform keeps sending is a poll of
1001
+ // `GET /computers/:id` every fifteen seconds for the life of the session,
1002
+ // under a wait that keeps answering "nothing happened".
1003
+ try {
1004
+ new URL(url);
1005
+ }
1006
+ catch {
1007
+ // The URL is not interpolated. `events_url` lives inside `vnc`, which
1008
+ // is root-equivalent on the machine, and this sentence becomes
1009
+ // `state.reason` which poll_events prints. JSON-stringifying the raw
1010
+ // value put the token `withoutCredentials` exists to strip into model
1011
+ // context — a relative URL with a query token is the ordinary shape of
1012
+ // "the platform omitted the host" (adversarial review, OPL-4314).
1013
+ throw new SettledError(`${this.computerId} has an events_url this client cannot parse, ` +
1014
+ 'so there is nowhere to connect. This is the platform sending something unexpected rather ' +
1015
+ 'than a passing condition — screenshot and list_windows still work.');
1016
+ }
1017
+ return url;
1018
+ }
1019
+ if (!vnc) {
1020
+ // The platform could not reach the host holding this computer, so it sent
1021
+ // no connect surface at all. Weather, and the backoff is the right
1022
+ // response to it — deliberately not settled.
1023
+ throw new MandalaError(`the platform returned no connect surface for ${this.computerId}; its host may be unreachable`);
1024
+ }
1025
+ if (c.os === 'windows') {
1026
+ throw new SettledError(`${this.computerId} runs Windows, which has no event stream: there is nowhere in the ` +
1027
+ 'guest to run the watcher its guest half needs. Use screenshot and list_windows.');
1028
+ }
1029
+ throw new SettledError(`${this.computerId} has no events_url. Its host may predate the event stream (platform ` +
1030
+ 'OPL-3785), or this API key may be a watch-only one, which is not given window titles.');
1031
+ }
1032
+ /**
1033
+ * One connection, from the handshake to the close.
1034
+ *
1035
+ * Resolves `true` when the opening frame landed, which is what the caller
1036
+ * uses to tell a stream that keeps dropping from one that never started.
1037
+ */
1038
+ #connection(url) {
1039
+ // The same check as the one in `#loop`, kept here as well because this is
1040
+ // the method that would leak the socket, and a second caller must not be
1041
+ // able to reintroduce the leak by forgetting.
1042
+ if (this.#abort.signal.aborted)
1043
+ return Promise.resolve(false);
1044
+ this.#greeted = false;
1045
+ return new Promise((resolve) => {
1046
+ // Whether this connection can be handed events it missed. A connection
1047
+ // with no continuity is joining at the head, and everything before it is
1048
+ // simply not this stream's to report.
1049
+ const resuming = Boolean(this.#resume);
1050
+ let socket;
1051
+ try {
1052
+ // `since` is this subscription's own place, never the model's. The two
1053
+ // are different positions on purpose: the model may be four turns behind
1054
+ // and the socket must not re-request what is already in the ring.
1055
+ //
1056
+ // Inside the try with the socket it is for, so that nothing in this
1057
+ // executor can reject: a rejection here escapes `#loop` — which does
1058
+ // not catch — and `#run` turns it into a terminal `stopped`, which is
1059
+ // not what a failure to open one connection means. `#url()` has already
1060
+ // settled a value that cannot parse, so this is belt and braces on the
1061
+ // path that used to throw.
1062
+ const target = new URL(url);
1063
+ if (this.#resume)
1064
+ target.searchParams.set('since', this.#resume);
1065
+ // Repeated rather than comma-joined, which is the platform's own
1066
+ // decision and worth mirroring exactly: a directory may contain a
1067
+ // comma, and a list format that cannot represent every value it is a
1068
+ // list of is a bug waiting for the first tenant with one.
1069
+ //
1070
+ // `append` and not `set`: the second call to `set` replaces the first.
1071
+ target.searchParams.delete('watch');
1072
+ // Snapshotted as it goes on the URL. This is what the opening frame's
1073
+ // `watching` is measured against, and what says whether a nomination
1074
+ // made a moment ago is on this connection or on the next one.
1075
+ //
1076
+ // The nominations MINUS whatever is being withheld: a tree proven to be
1077
+ // one this host will not carry, and the one currently under suspicion
1078
+ // for it. Both stay in `#watches`, because they are still what the
1079
+ // caller asked for and the difference belongs on the wire rather than
1080
+ // in this client's record of the request.
1081
+ this.#sent = this.#watches.filter((w) => !this.#watchRefused.has(w) && w !== this.#shedCandidate);
1082
+ for (const w of this.#sent)
1083
+ target.searchParams.append('watch', w);
1084
+ socket = this.#socketFor(target.toString());
1085
+ }
1086
+ catch {
1087
+ return resolve(false);
1088
+ }
1089
+ this.#socket = socket;
1090
+ if (this.#state.status !== 'stopped')
1091
+ this.#state = { status: 'connecting' };
1092
+ let settled = false;
1093
+ let opened = false;
1094
+ const finish = (reached) => {
1095
+ if (settled)
1096
+ return;
1097
+ settled = true;
1098
+ clearTimeout(timer);
1099
+ this.#abort.signal.removeEventListener('abort', onAbort);
1100
+ // A connection that has ended is not carrying anything, whatever it was
1101
+ // greeted with. Without this the window between one connection ending
1102
+ // and the next opening reads as a tree still on the wire.
1103
+ this.#greeted = false;
1104
+ if (this.#socket === socket)
1105
+ this.#socket = undefined;
1106
+ try {
1107
+ socket.close();
1108
+ }
1109
+ catch {
1110
+ // A socket that is already closed throws on some implementations and
1111
+ // not on others, and there is nothing to do about it either way.
1112
+ }
1113
+ if (this.#state.status !== 'stopped')
1114
+ this.#state = { status: 'connecting' };
1115
+ this.#wakeAll();
1116
+ resolve(reached);
1117
+ };
1118
+ // A handshake with nothing behind it — a host that accepts the TCP
1119
+ // connection and never upgrades — would otherwise hold this connection
1120
+ // open forever, and the reconnect that would have found a working host
1121
+ // never runs.
1122
+ const timer = setTimeout(() => finish(opened), CONNECT_TIMEOUT_MS);
1123
+ const onAbort = () => finish(opened);
1124
+ this.#abort.signal.addEventListener('abort', onAbort, { once: true });
1125
+ // Both listeners are guarded, because `finish()` does not stop a socket
1126
+ // from delivering. A `close()` is a handshake rather than an instant, and
1127
+ // a connect-timeout `finish` leaves the socket open by definition — so
1128
+ // frames from connection A could still arrive after `#loop` had given up
1129
+ // on it and opened connection B, into the same subscription: a second
1130
+ // synthesized readiness, events B will replay, a `stopped` state walked
1131
+ // back to `open` with `#loop` already returned.
1132
+ const mine = () => !settled && this.#socket === socket;
1133
+ socket.addEventListener('open', () => {
1134
+ if (!mine())
1135
+ return;
1136
+ this.#state = { status: 'open' };
1137
+ });
1138
+ socket.addEventListener('message', (ev) => {
1139
+ if (!mine())
1140
+ return;
1141
+ const frame = parse(ev.data);
1142
+ if (!frame)
1143
+ return;
1144
+ if (frame.type === 'hello') {
1145
+ opened = true;
1146
+ clearTimeout(timer);
1147
+ this.#onHello(frame, resuming);
1148
+ return;
1149
+ }
1150
+ this.#onFrame(frame);
1151
+ });
1152
+ socket.addEventListener('error', () => finish(opened));
1153
+ socket.addEventListener('close', () => finish(opened));
1154
+ });
1155
+ }
1156
+ #onHello(frame, resuming) {
1157
+ const hello = {
1158
+ computer: str(frame.computer) ?? this.computerId,
1159
+ cursor: str(frame.cursor) ?? '',
1160
+ // TRUE only. A readiness nobody claimed is a readiness to wait for, which
1161
+ // is the recoverable half of being wrong: waiting on a desktop that is up
1162
+ // ends at the caller's timeout, while concluding a desktop is up because
1163
+ // a field was malformed hands the model a screen that is still booting.
1164
+ ready: frame.ready === true,
1165
+ events: list(frame.events) ?? [],
1166
+ windows: Array.isArray(frame.windows) ? frame.windows : undefined,
1167
+ watching: watched(frame.watching),
1168
+ };
1169
+ this.#hello = hello;
1170
+ this.#types = hello.events;
1171
+ this.#greeted = true;
1172
+ // Here and not where the connection ends, which is the only place this
1173
+ // could be observed from otherwise — and a connection that WORKS does not
1174
+ // end for minutes or hours. An experiment whose positive result was only
1175
+ // read on the way out is an experiment with no result.
1176
+ this.#cleared();
1177
+ this.#adoptWatching(hello.watching);
1178
+ this.#resume ??= hello.cursor || undefined;
1179
+ this.#start ??= hello.cursor || undefined;
1180
+ this.#state = { status: 'open' };
1181
+ // `computer.ready` fires once per desktop SESSION, so a stream that
1182
+ // attaches to a machine which has been up for an hour will never be sent
1183
+ // one — and a `wait_for_event(["computer.ready"])` over the raw socket
1184
+ // waits forever on a desktop that is already there. The opening frame says
1185
+ // which it is, and this is that answer arriving in the shape the model is
1186
+ // already reading.
1187
+ //
1188
+ // Only on a connection with no continuity. A resume either already had the
1189
+ // readiness or is about to be handed it out of the backlog, so nothing is
1190
+ // invented there. Per CONNECTION and not latched across them, which is the
1191
+ // lesson of the SDK's OPL-4206: a latch that remembered "already told them"
1192
+ // suppressed the readiness of a desktop the caller had never heard of,
1193
+ // because a display manager can be restarted inside a running computer and
1194
+ // that is a new session. One extra readiness is the cheaper wrong answer.
1195
+ if (hello.ready && !resuming)
1196
+ this.#pushReady(hello.cursor);
1197
+ this.#wakeAll();
1198
+ }
1199
+ /**
1200
+ * Take the host's word for what this stream is watching.
1201
+ *
1202
+ * Its spelling and not ours, and that is the point: the host normalises a
1203
+ * nomination — a trailing slash and a `.` segment are cleaned away — and the
1204
+ * cleaned form is what every `file.changed` carries in `watch`. A client that
1205
+ * went on matching what it SENT would match nothing the first time the two
1206
+ * differed, and this server cleans a path the same way precisely so that they
1207
+ * do not — which is a reason to check rather than a reason not to look.
1208
+ *
1209
+ * The armed map is REPLACED rather than merged, because a new connection is
1210
+ * where the authoritative answer lives. The guest answers a nomination once,
1211
+ * so a tree somebody else armed sends this connection no event at all and the
1212
+ * opening frame is the only place its state is stated. Merging would leave a
1213
+ * stale `true` on a tree that had since gone unwatchable, which is the
1214
+ * wait-forever bug pointed the other way.
1215
+ */
1216
+ #adoptWatching(watching) {
1217
+ // Measured against what THIS connection sent rather than against the
1218
+ // current nomination set, which are the same list except in the one case
1219
+ // that matters: a nomination made while a connection was in flight.
1220
+ const sent = this.#sent;
1221
+ // Both cleared whatever the answer. An alias belongs to the connection that
1222
+ // stated it, and a stale one would map a nomination onto a name the host is
1223
+ // no longer using — every event under it dropped as being about a tree
1224
+ // nobody asked for. And a connection carrying no watches is watching
1225
+ // nothing, so a surviving `armed` would be a live tree that is not one.
1226
+ this.#hostName.clear();
1227
+ const armed = new Map();
1228
+ if (!sent.length) {
1229
+ this.#watchingEchoed = undefined;
1230
+ this.#armed = armed;
1231
+ return;
1232
+ }
1233
+ // A host answering about a different number of trees than it was asked
1234
+ // about has said something this client cannot line up, and guessing which
1235
+ // nomination it dropped would be inventing the one fact the caller needs.
1236
+ // Read as "not honoured", which is the answer that gets said out loud.
1237
+ this.#watchingEchoed = watching !== undefined && watching.length === sent.length;
1238
+ if (!watching || !this.#watchingEchoed) {
1239
+ this.#armed = armed;
1240
+ return;
1241
+ }
1242
+ sent.forEach((nominated, i) => {
1243
+ const w = watching[i];
1244
+ if (w.path !== nominated)
1245
+ this.#hostName.set(nominated, w.path);
1246
+ armed.set(nominated, w.armed);
1247
+ // A tree that comes up armed on a connection that found it unarmed is a
1248
+ // real transition and bumps the generation; one that was already armed is
1249
+ // the same arming reported again by a socket that reconnected under it,
1250
+ // and bumping there would tell every waiter to go and re-read a tree
1251
+ // nothing had interrupted.
1252
+ //
1253
+ // And the arming is what clears a standing loss, which is why only a
1254
+ // TRANSITION does. The guest reports `budget` when it installs the watch
1255
+ // and finds the tree too big, so a tree that really re-armed is about to
1256
+ // say so again if it is still true — but a connection whose `hello` says
1257
+ // a tree was armed all along did not re-arm anything, and clearing there
1258
+ // would drop a permanent condition that nothing puts back. `hello.watching`
1259
+ // has no field for it, so the flag is the only record there is.
1260
+ if (w.armed && !this.isArmed(nominated)) {
1261
+ this.#bumpArm(nominated);
1262
+ this.#watchLost.delete(nominated);
1263
+ }
1264
+ else if (this.#watchLost.get(nominated) === 'unwatchable') {
1265
+ // Cleared on any new connection, unlike `budget`, because a tree that
1266
+ // is not armed is one the guest is about to be asked about again — and
1267
+ // it will answer `unwatchable` a second time if the directory is still
1268
+ // missing. Carried forward it would refuse a wait on a tree that had
1269
+ // since appeared.
1270
+ this.#watchLost.delete(nominated);
1271
+ }
1272
+ });
1273
+ this.#armed = armed;
1274
+ }
1275
+ /**
1276
+ * What one connection's outcome says about the watches it carried.
1277
+ *
1278
+ * The one refusal a websocket cannot report, answered by elimination — and it
1279
+ * has to be an elimination actually carried out rather than a guess dressed
1280
+ * as one. A watch is the only thing on this URL a host refuses outright: a
1281
+ * path it will not honour is a 400 on the UPGRADE and a nomination past the
1282
+ * 32 trees a computer will watch is a 409, and neither status nor body
1283
+ * reaches a websocket client. Left alone, one nomination the host will not
1284
+ * take is the whole stream gone — no windows, no process exits, no readiness,
1285
+ * reconnecting forever with nothing ever saying why.
1286
+ *
1287
+ * But a host that is down fails in exactly the same way, so two missed
1288
+ * handshakes are not proof of anything. The experiment is: drop the newest
1289
+ * tree and try again WITHOUT it. If that connection greets, the tree was the
1290
+ * problem and this server can say so. If it fails too, the tree was innocent
1291
+ * — it goes back, and nothing is claimed about the host beyond its being
1292
+ * unreachable, which the reconnect loop was already handling.
1293
+ */
1294
+ #cleared() {
1295
+ // Whatever was withheld is now proven guilty: this is the same stream
1296
+ // without it, and it opened.
1297
+ if (this.#shedCandidate !== undefined) {
1298
+ this.#watchRefused.add(this.#shedCandidate);
1299
+ this.#shedCandidate = undefined;
1300
+ }
1301
+ this.#lastGood = [...this.#sent];
1302
+ this.#upgradeFailures = 0;
1303
+ this.#shedRuledOut = false;
1304
+ }
1305
+ /** @see {@link #cleared} — the other half, for a connection that never greeted. */
1306
+ #blamed() {
1307
+ if (this.#shedCandidate !== undefined) {
1308
+ // The experiment came back negative. The tree goes back on the URL and
1309
+ // watches stop being blamed until something connects — otherwise a host
1310
+ // that is simply down would shed its way through every tree in the set,
1311
+ // reporting each in turn as one the host would not carry.
1312
+ this.#shedCandidate = undefined;
1313
+ this.#shedRuledOut = true;
1314
+ this.#upgradeFailures = 0;
1315
+ this.#wakeAll();
1316
+ return;
1317
+ }
1318
+ if (!this.#sent.length || this.#shedRuledOut)
1319
+ return;
1320
+ if (++this.#upgradeFailures < WATCH_SHED_AFTER)
1321
+ return;
1322
+ this.#upgradeFailures = 0;
1323
+ // Whatever changed since this stream last worked, and only then the newest.
1324
+ // After an LRU refresh "newest" means "most recently asked about", which is
1325
+ // the opposite of a good suspect: the tree a caller keeps asking about is
1326
+ // the one least likely to be new. What the last connection that actually
1327
+ // greeted was carrying is the real before-and-after.
1328
+ const carried = this.#sent;
1329
+ this.#shedCandidate =
1330
+ carried.find((w) => !this.#lastGood.includes(w)) ?? carried[carried.length - 1];
1331
+ this.#wakeAll();
1332
+ }
1333
+ #bumpArm(path) {
1334
+ this.#armGen.set(path, (this.#armGen.get(path) ?? 0) + 1);
1335
+ }
1336
+ /**
1337
+ * What a `file.changed` says about the TREE, as opposed to about a file.
1338
+ *
1339
+ * Three payload shapes share one type here, and only one of them is a change:
1340
+ * `{watch, path, kind, dir}` is a file, `{watch, armed}` is the tree becoming
1341
+ * live, and `{watch, lost}` is the tree saying this stream's picture of it is
1342
+ * wrong. The last two are state, and are recorded BEFORE the event is pushed
1343
+ * so that a waiter woken by the push reads the state the push is about.
1344
+ *
1345
+ * Only `unwatchable` disarms. `flood` and `budget` both say the tree IS being
1346
+ * watched and is being reported incompletely — treating them as a disarm
1347
+ * would answer the next wait with "this tree is not being watched" about a
1348
+ * tree that is, forever, because nothing would arm it again.
1349
+ */
1350
+ #onFileFrame(frame) {
1351
+ const data = frame.data;
1352
+ const named = str(data?.watch);
1353
+ if (!named)
1354
+ return;
1355
+ // Back into this client's own spelling, since that is what every nomination
1356
+ // is keyed by here. Ours already in the ordinary case, where the host's
1357
+ // normalisation and this file's agree and the map is empty.
1358
+ const watch = this.#watches.find((w) => this.hostPath(w) === named);
1359
+ if (!watch)
1360
+ return;
1361
+ const lost = str(data?.lost);
1362
+ if (data?.armed === true) {
1363
+ this.#watchLost.delete(watch);
1364
+ // Always a transition, never a restatement, and that is the host's
1365
+ // guarantee rather than an assumption. The guest re-states `armed` for
1366
+ // every tree it is already watching whenever the host nominates anything
1367
+ // — which happens whenever any subscriber on this computer arrives — and
1368
+ // the host DROPS those, delivering one only when its own record says the
1369
+ // tree was not armed. So a frame that gets here means the watch really
1370
+ // was interrupted and is reporting from HERE, and anything that happened
1371
+ // in between was never reported.
1372
+ //
1373
+ // Compared against this client's own idea of armed instead, a re-arm
1374
+ // after a stop and a start would look like a restatement — nothing here
1375
+ // clears the flag when the link goes down — and the wait that should have
1376
+ // said "re-read the tree" would have gone on waiting on a tree whose
1377
+ // history had a hole in it.
1378
+ this.#bumpArm(watch);
1379
+ this.#armed.set(watch, true);
1380
+ return;
1381
+ }
1382
+ if (lost) {
1383
+ // Only the standing ones. `budget` says part of this tree is not being
1384
+ // watched and stays true until a narrower path is nominated; a later wait
1385
+ // on it must not answer "nothing changed" as though the whole tree had
1386
+ // been covered. `unwatchable` says none of it is. A `flood` is neither: it
1387
+ // is a burst that is over, and holding onto one would make every answer
1388
+ // about a tree that ever saw a build hedge for the rest of the session.
1389
+ if (lost === 'budget' || lost === 'unwatchable')
1390
+ this.#watchLost.set(watch, lost);
1391
+ if (lost === 'unwatchable')
1392
+ this.#armed.set(watch, false);
1393
+ }
1394
+ }
1395
+ /** The readiness that already happened, in the shape the model is reading. */
1396
+ #pushReady(cursor) {
1397
+ this.#push({
1398
+ type: 'computer.ready',
1399
+ at: new Date().toISOString(),
1400
+ computer: this.computerId,
1401
+ cursor,
1402
+ source: 'daemon',
1403
+ data: {},
1404
+ // Flagged rather than passed off as the real thing, because it is not
1405
+ // one: it has no `seq`, and its `at` is when this server attached rather
1406
+ // than when the desktop came up.
1407
+ synthesized: true,
1408
+ });
1409
+ }
1410
+ #onFrame(frame) {
1411
+ const cursor = str(frame.cursor);
1412
+ if (frame.type === 'gap') {
1413
+ // Never delivered. The model has no documented fallback for a `gap` and
1414
+ // no way to be told one mid-stream, so handing it the frame is handing it
1415
+ // a recovery procedure to invent. What it gets instead is a sentence and,
1416
+ // from the tool, the state it would have gone to reconcile against.
1417
+ const detail = str(frame.detail);
1418
+ this.#loss = {
1419
+ events: null,
1420
+ reason: detail
1421
+ ? `the platform could not replay that far: ${detail}`
1422
+ : 'the platform could not replay from where this stream had got to, so some events are gone',
1423
+ };
1424
+ // A gap's own cursor is where the replayable history now starts. Resuming
1425
+ // from it is legal and is what keeps the next reconnect from asking for
1426
+ // the same missing window again.
1427
+ const oldest = str(frame.data?.oldest_cursor);
1428
+ if (cursor)
1429
+ this.#resume = cursor;
1430
+ else if (oldest)
1431
+ this.#resume = oldest;
1432
+ // A gapped resume counts as no continuity, so the readiness `#onHello`
1433
+ // declined to synthesize — because this connection was resuming, and a
1434
+ // resuming connection is about to be handed the backlog — has to be made
1435
+ // here instead. The backlog it would have been in is precisely what the
1436
+ // gap says is gone. Once per gap, and a second one is not a duplicate to
1437
+ // suppress: a display manager restarted inside a running computer is a
1438
+ // new desktop session, and a gap is exactly where the event saying so
1439
+ // went missing (the SDK's OPL-4206).
1440
+ // The floor moves with the gap when the model has been handed nothing
1441
+ // yet: the history before this point is what the gap says is gone, so a
1442
+ // replacement subscription resuming from where this one STARTED would ask
1443
+ // for a window that cannot be replayed and be told so a second time.
1444
+ if (!this.#deliveredCursor)
1445
+ this.#start = this.#resume;
1446
+ // Stamped with the GAP's position, not the opening frame's. A resumed
1447
+ // connection's `hello` carries the cursor it attached at, which can be
1448
+ // older than events the model has already been handed — and a synthesized
1449
+ // event carrying it would walk `#deliveredCursor` backwards, so the next
1450
+ // `since` re-delivered what had already been read, silently, with no gap
1451
+ // to report it.
1452
+ if (this.#hello?.ready)
1453
+ this.#pushReady(this.#resume ?? this.#hello.cursor);
1454
+ this.#wakeAll();
1455
+ return;
1456
+ }
1457
+ if (frame.type === 'capabilities') {
1458
+ // Replaces what `hello` advertised. It goes both ways: a guest that turns
1459
+ // out to have no watcher withdraws the half `hello` promised, and a
1460
+ // computer stopped and started under an open socket can acquire it.
1461
+ const events = list(frame.events);
1462
+ if (events)
1463
+ this.#types = events;
1464
+ this.#wakeAll();
1465
+ return;
1466
+ }
1467
+ // `closed` is this host saying it is ending the socket on purpose rather
1468
+ // than the socket simply dying. Nothing here needs to act on the
1469
+ // difference: the reconnect re-reads the computer either way, and that read
1470
+ // is what tells a machine somebody stopped from one that merely moved.
1471
+ if (STREAM_FRAMES.has(frame.type))
1472
+ return;
1473
+ if (frame.type === 'file.changed')
1474
+ this.#onFileFrame(frame);
1475
+ if (cursor)
1476
+ this.#resume = cursor;
1477
+ this.#push(frame);
1478
+ }
1479
+ }
1480
+ /**
1481
+ * Every computer this session is listening to.
1482
+ *
1483
+ * One per session and not one per process, for the reason `Session` itself is:
1484
+ * over the HTTP transport each caller arrives with their own key, and a
1485
+ * process-wide hub would hold one caller's socket open for another caller's
1486
+ * computer.
1487
+ */
1488
+ export class EventHub {
1489
+ #api;
1490
+ #socketFor;
1491
+ #subs = new Map();
1492
+ /**
1493
+ * Where each computer's stream had got to when its subscription went away,
1494
+ * and what it was watching.
1495
+ *
1496
+ * A reap is not a decision to forget. Five minutes without a tool call is an
1497
+ * ordinary thing for a model to do — a long `exec`, a detour onto another
1498
+ * machine — and a subscription that reopened at the head afterwards would
1499
+ * lose exactly the `process.exited` the detour was waiting on. Reopening with
1500
+ * this asks the platform to replay from there instead, and where it cannot,
1501
+ * the answer is an honest gap rather than silence.
1502
+ *
1503
+ * The nominations ride with the cursor because they have the same shape of
1504
+ * consequence and a worse failure. A tree is watched by the CONNECTION, so a
1505
+ * reap ends every watch on that computer — and a stream that came back
1506
+ * watching nothing would answer the next question about a tree with a
1507
+ * perfectly ordinary "nothing changed", over a window during which nothing
1508
+ * was looking. One map rather than two, so an entry is evicted whole.
1509
+ */
1510
+ #memory = new Map();
1511
+ /** Bound, so a session that touches thousands of computers cannot grow forever. */
1512
+ static #MAX_REMEMBERED = 256;
1513
+ #sweep;
1514
+ constructor(api, socketFor = defaultEventSocket) {
1515
+ this.#api = api;
1516
+ this.#socketFor = socketFor;
1517
+ }
1518
+ /** This computer's subscription, opened if this is the first ask. */
1519
+ open(computerId) {
1520
+ let sub = this.#subs.get(computerId);
1521
+ if (!sub) {
1522
+ const held = this.#memory.get(computerId);
1523
+ sub = new Subscription(this.#api, computerId, this.#socketFor, held?.cursor, held?.watches);
1524
+ this.#subs.set(computerId, sub);
1525
+ sub.start();
1526
+ this.#startSweep();
1527
+ }
1528
+ sub.touch();
1529
+ return sub;
1530
+ }
1531
+ /**
1532
+ * Drop one, so the next ask opens a fresh socket and a fresh buffer.
1533
+ *
1534
+ * `remember` is the difference between a stream this session may want to
1535
+ * resume — reaped for idleness, stopped on a computer somebody suspended —
1536
+ * and one there is nothing left to resume: a deleted computer's cursor names
1537
+ * a position in a stream that no longer exists.
1538
+ */
1539
+ drop(computerId, reason, remember = false) {
1540
+ const sub = this.#subs.get(computerId);
1541
+ if (!sub)
1542
+ return;
1543
+ const at = sub.resumeCursor;
1544
+ const watches = sub.nominations;
1545
+ sub.close(reason);
1546
+ this.#subs.delete(computerId);
1547
+ if (remember && (at || watches.length)) {
1548
+ // Delete before set so a repeated id is the newest entry and the oldest
1549
+ // is the one evicted.
1550
+ this.#memory.delete(computerId);
1551
+ this.#memory.set(computerId, { cursor: at, watches });
1552
+ for (const key of this.#memory.keys()) {
1553
+ if (this.#memory.size <= EventHub.#MAX_REMEMBERED)
1554
+ break;
1555
+ this.#memory.delete(key);
1556
+ }
1557
+ }
1558
+ else {
1559
+ this.#memory.delete(computerId);
1560
+ }
1561
+ if (!this.#subs.size)
1562
+ this.#stopSweep();
1563
+ }
1564
+ /** Close every socket. The session is over, or the process is going away. */
1565
+ closeAll() {
1566
+ for (const [id, sub] of this.#subs) {
1567
+ sub.close('the session ended');
1568
+ this.#subs.delete(id);
1569
+ }
1570
+ this.#memory.clear();
1571
+ this.#stopSweep();
1572
+ }
1573
+ #startSweep() {
1574
+ if (this.#sweep)
1575
+ return;
1576
+ this.#sweep = setInterval(() => {
1577
+ for (const [id, sub] of this.#subs) {
1578
+ // A stopped subscription is kept until something asks about it, so the
1579
+ // reason it stopped — suspended, deleted, Windows — is still there to
1580
+ // be reported once. The idle window is what eventually takes it.
1581
+ if (sub.idleMs < IDLE_REAP_MS)
1582
+ continue;
1583
+ this.drop(id, 'nothing asked about this computer for five minutes', true);
1584
+ }
1585
+ if (!this.#subs.size)
1586
+ this.#stopSweep();
1587
+ }, SWEEP_MS);
1588
+ // A timer is not a reason for a process to stay alive. Without this an
1589
+ // stdio server whose client has gone away, and every test that ever opened
1590
+ // a stream, hangs at exit until the sweep is cleared by hand.
1591
+ this.#sweep.unref?.();
1592
+ }
1593
+ #stopSweep() {
1594
+ if (!this.#sweep)
1595
+ return;
1596
+ clearInterval(this.#sweep);
1597
+ this.#sweep = undefined;
1598
+ }
1599
+ }
1600
+ /**
1601
+ * A failure that answers the same way however often it is asked.
1602
+ *
1603
+ * The reconnect loop's one branch: everything else is weather and is backed
1604
+ * off, and this ends the stream with the sentence saying why.
1605
+ */
1606
+ class SettledError extends MandalaError {
1607
+ }
1608
+ /** One text frame, as an object, or `undefined` for anything that is not one. */
1609
+ function parse(data) {
1610
+ const text = typeof data === 'string'
1611
+ ? data
1612
+ : data instanceof Uint8Array
1613
+ ? new TextDecoder().decode(data)
1614
+ : undefined;
1615
+ if (text === undefined)
1616
+ return undefined;
1617
+ let frame;
1618
+ try {
1619
+ frame = JSON.parse(text);
1620
+ }
1621
+ catch {
1622
+ return undefined;
1623
+ }
1624
+ if (!frame || typeof frame !== 'object' || Array.isArray(frame))
1625
+ return undefined;
1626
+ const type = frame.type;
1627
+ if (typeof type !== 'string' || !type)
1628
+ return undefined;
1629
+ return frame;
1630
+ }
1631
+ const str = (v) => (typeof v === 'string' && v ? v : undefined);
1632
+ const list = (v) => Array.isArray(v) ? v.filter((e) => typeof e === 'string') : undefined;
1633
+ /**
1634
+ * `hello.watching`, or `undefined` for a frame that carried none.
1635
+ *
1636
+ * `armed` is read as TRUE ONLY, on the same reasoning as `ready` above: an
1637
+ * armedness nobody claimed is one to wait for, and waiting on a tree that is
1638
+ * already live ends when the next thing happens under it. Concluding a tree is
1639
+ * live because a field was malformed is the unrecoverable half — it hands a
1640
+ * model a silence it will read as "nothing changed".
1641
+ */
1642
+ const watched = (v) => {
1643
+ if (!Array.isArray(v))
1644
+ return undefined;
1645
+ const out = [];
1646
+ for (const e of v) {
1647
+ if (!e || typeof e !== 'object')
1648
+ continue;
1649
+ const path = str(e.path);
1650
+ if (!path)
1651
+ continue;
1652
+ out.push({ path, armed: e.armed === true });
1653
+ }
1654
+ return out;
1655
+ };
1656
+ /**
1657
+ * A pause that ends early when the stream is closed.
1658
+ *
1659
+ * `unref`'d for the reason the idle sweep is: a timer is not a reason for a
1660
+ * process to stay alive. The reconnect backoff is the one timer a subscription
1661
+ * holds while it has no socket, and a ref'd one kept an stdio server up after
1662
+ * its client had gone. Nothing is lost by it — a client that is still attached
1663
+ * holds stdin, which keeps the loop running on its own — and it is a backstop
1664
+ * rather than the fix: `runStdio` closes the session on stdin EOF, and this is
1665
+ * what covers the window where the socket is already down.
1666
+ */
1667
+ const sleep = (ms, signal) => new Promise((resolve) => {
1668
+ if (signal.aborted || ms <= 0)
1669
+ return resolve();
1670
+ const t = setTimeout(done, ms);
1671
+ t.unref?.();
1672
+ function done() {
1673
+ clearTimeout(t);
1674
+ signal.removeEventListener('abort', done);
1675
+ resolve();
1676
+ }
1677
+ signal.addEventListener('abort', done, { once: true });
1678
+ });
1679
+ //# sourceMappingURL=events.js.map