Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/www/js/hand.js

65.5 KiB, 1 run

created by r2519314175:1375, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1/* hand.js — the machine hand's page-side relay.
2 *
3 * `window.DaimondHand` is the ONE interface the wasm `run` tool calls, exactly
4 * as `window.DaimondWeb` is for the web tools. It hides which transport is
5 * attached, so the tool does not change when the second one appears:
6 *
7 * 'none' no hand. Every command refuses with a sentence saying so, and
8 * the app is otherwise unaffected — the file tools never needed it.
9 *
10 * 'machine' the Daimond Hands extension is installed and a native messaging
11 * host is paired. Chrome launches that host and connects it to
12 * THIS extension only; the extension is reachable from this origin
13 * only. There is no port and no secret, because the browser is the
14 * doorman.
15 *
16 * 'cloud' the same host binary over a WebSocket on a machine the user
17 * owns, so a phone can drive a desktop. Not built yet; the shape
18 * is here so that adding it is not a rewrite.
19 *
20 * Why a native messaging host and not a small server on localhost: a loopback
21 * port is reachable by ANY page the user visits, is not secret, and is guessable
22 * in a second. The whole defence would come down to one pasted secret. Handing
23 * the doorman's job to the browser removes the question instead of answering it.
24 *
25 * Streaming matters here in a way it does not for the web tools. A `cargo test`
26 * says nothing for a minute and then says everything; a person watching an
27 * empty panel cannot tell that from a hang. So output is relayed to the panel
28 * as it arrives, while the PROMISE resolves only at the end — because a tool
29 * result is one blob and the model reads it once. The live stream is for the
30 * person; the resolved result is for the daimon.
31 *
32 * ── One link, greeted once ──────────────────────────────────────────
33 *
34 * There is ONE port to the extension, opened on the first thing that needs a
35 * hand and kept for as long as it lives, and every command travels on it. Not a
36 * port per command, for two reasons. Chrome starts a fresh host process per
37 * connection, so a port per command would be a process per command and a
38 * handshake per command; and the handshake is what tells us the folder the user
39 * granted, without which no fence can be expressed and nothing may be run. Runs
40 * are told apart by the `id` the wire already carries for exactly that purpose,
41 * and so is a terminal session: `send` and `subscribe` put a second KIND of
42 * conversation on the one link rather than a second connection to the machine.
43 * See js/handpty.js, which owns no transport and asks for exactly those two.
44 *
45 * The link is opened LAZILY. Opening it is what puts the approval window on the
46 * user's screen, and a window that appears because the app started — rather than
47 * because a daimon asked to run something — is a question with no context, which
48 * is the kind a person learns to dismiss.
49 */
50(function () {
51 'use strict';
52
53 /// What a person reads in the panel. Model-facing strings — what a tool call
54 /// returns — stay in the language the system prompt is written in, as in web.js.
55 function t(k, v) { return window.DaimondI18n ? DaimondI18n.t(k, v) : k; }
56
57 /// The wire protocol this page speaks. The hand answers with its own and the
58 /// two settle it between them; a mismatch is the hand's sentence to write,
59 /// not ours, because it is the end that knows both numbers.
60 var PROTO = 2;
61
62 var state = {
63 transport: 'none', // 'none' | 'machine' | 'cloud'
64 extId: '',
65 machine: '', // what the hand calls the box it runs on
66 version: '', // which build of the hand answered
67 os: '', // in the wire's own vocabulary: linux | macos | windows
68 root: '', // the absolute folder the grant covers
69 caps: [], // what the hand can actually enforce here
70 };
71
72 var deps = {}; // { onChunk, onStart, onEnd, note, client } — supplied by daimond.js
73 var live = {}; // id -> run record
74 var link = null; // the one port: { port, greeted, waiters, note }
75
76 /// Handlers watching one id, for a conversation this file does not itself
77 /// carry. A terminal session is the first: it travels on THIS link, told
78 /// apart by the same `id` a run is, because a second port would be a second
79 /// host process and a second approval question for a hand the user granted
80 /// once (see the header, and js/handpty.js).
81 var subs = {}; // id -> [fn, ...]
82 // ── Waiting for an answer that carries no id ────────────────────
83 //
84 // `Req::Runs` takes nothing, on purpose: a field would be a filter, and a
85 // filter is a selector, and the one selector that message must never grow is
86 // one naming a process the hand did not start. So its answer carries no id
87 // either, and there is nothing to route it BY -- which is how a `runs` reply
88 // came to be dropped at the run switch below for having no `id` while the
89 // hand was answering it correctly.
90 //
91 // A queue, and not a single slot: two questions asked at once are answered
92 // in the order they were asked, over one ordered port, so the oldest waiter
93 // takes the oldest answer. An id-less ERROR settles the oldest waiter too --
94 // the hand sends one when it cannot say what it is running, and the
95 // extension sends one when the link itself has failed, and neither leaves an
96 // answer coming.
97 var runsWait = []; // [{ resolve, reject, timer }, ...], oldest first
98 var dirsWait = []; // the same, for the folder browser
99 var grantWait = []; // and for recording the folder chosen
100
101 // ── What the reload grace left behind ───────────────────────────
102 //
103 // A page that goes away no longer takes the machine hand with it at once:
104 // the extension parks the pair for thirty seconds and the same tab, reloaded,
105 // adopts it. Two messages arrive out of that, and this page is the only place
106 // either can be turned into something a daimon reads.
107 //
108 // `resumed` this page has taken over a hand that was held for it, and what
109 // arrived while nothing was attached follows.
110 // `lapsed` the hold ran out before this page arrived, and what it was
111 // holding was stopped.
112 //
113 // NEITHER IS A RUN OF THIS PAGE'S. The turn that started them ended with the
114 // page that ended, so there is no promise to settle and no `live` record to
115 // absorb into. The output would therefore be dropped at the run switch, which
116 // is the lie the grace exists to avoid: a daimon that re-attaches and silently
117 // misses thirty seconds of a build is worse off than one whose build was
118 // honestly killed. So it is kept HERE, by id, and `runs` is where a reader
119 // meets it.
120
121 /// The sentence about the last grace, or ''. Read once by `runs` and cleared:
122 /// it is news about one gap, not a standing condition.
123 var gapNews = '';
124
125 /// Output that arrived for a run this page did not start, by id.
126 /// `{ out: [], err: [], bytes, ended }`.
127 var carried = {};
128
129 /// The most this page keeps of it, over every id at once. A `cargo build`
130 /// outruns any buffer worth holding in a tab, and the extension's own hold is
131 /// bounded for the same reason; what is let go is COUNTED and said.
132 var CARRIED_MAX = 256 * 1024;
133 var carriedBytes = 0;
134 var carriedLost = 0;
135
136 /// Whether a hand has ever answered in this page. It is the difference
137 /// between "you have not installed it" and "it stopped", and those are
138 /// different instructions to a user (see §1.16 of `hand/REVIEW.md`): telling
139 /// someone to install software they already have wastes their afternoon.
140 var met = false;
141
142 /// The prefix the granted root arrives under, inside `caps`.
143 ///
144 /// **A workaround, and recorded as one.** `Resp::Hello` in `hand/src/wire.rs`
145 /// has no `root` field, and the wire is fixed, so the host reports the folder
146 /// it was granted as a capability entry — `root:/home/u/work` — and the page
147 /// reads it back out. `hello.root` is preferred when it exists, so the day
148 /// the wire grows the field this line stops being load-bearing on its own.
149 var ROOT_CAP = 'root:';
150
151 /// The prefix the FOLDER IDENTITY arrives under, inside `caps`.
152 ///
153 /// `root:` says WHERE the hand will work; this is what lets the page find out whether that
154 /// is the folder it is itself looking at. A path cannot settle it: the File System Access
155 /// API hands the page a handle and never a path, so the two ends compare a token written
156 /// into a file both can reach.
157 ///
158 /// The hand writes a random 32-hex token to `<root>/.daimond/workspace.id`, once, and keeps
159 /// it — so it identifies the FOLDER rather than the run, and a page that remembers it
160 /// notices its workspace being swapped underneath it. The file lives inside `.daimond`
161 /// deliberately: a fence always denies that directory, so a command cannot read the token,
162 /// and a command that has been talked into helping cannot answer a challenge about a folder
163 /// it is not in.
164 var WS_CAP = 'ws:';
165
166 /// What the hand publishes where it could not establish an identity at all.
167 ///
168 /// A literal word, and a token can never be it: a token is 32 hexadecimal characters. One
169 /// string therefore settles both questions — "what is the identity" and "is there one".
170 var WS_UNPROVEN = 'unproven';
171
172 /// The name of the folder Daimond writes its own things into, and where the token lives.
173 var WS_DIR = '.daimond';
174 var WS_FILE = 'workspace.id';
175
176 /// The refusal a missing hand produces. It is the most-read sentence in this
177 /// file — a user who has not installed the hand meets it on their first
178 /// command — so it says what is missing and what still works, rather than
179 /// merely that something failed.
180 var NO_HAND = 'There is no machine hand paired with this browser, so there is '
181 + 'nothing to run commands on. Daimond runs in the browser, and a browser '
182 + 'cannot start a program; the hand is a small companion that can. Tell the '
183 + 'user it is not installed, and carry on with the file tools, which do not need it.';
184
185 /// What a hand that HAS answered before, and has now gone, produces.
186 ///
187 /// Kept apart from `NO_HAND` on purpose. Every disconnect used to be reported
188 /// as "not installed", so a host that crashed, was killed, or blew Chrome's
189 /// 1 MB cap made the daimon tell the user to install what they already had.
190 var HAND_GONE = 'The machine hand answered earlier and has now gone, so it is installed and '
191 + 'does not need installing again. Something stopped it — a crash, a quit, or a message '
192 + 'too large for the browser to carry. Try once more; if it stops a second time, close '
193 + 'any other browser window that has Daimond open, because only one of them at a time '
194 + 'can hold the hand.';
195
196 /// The longest the page waits for an acknowledgement of a command it has sent.
197 ///
198 /// Not the same as the command's own timeout, which the hand enforces and
199 /// which is the real limit. This is the wait after which the page stops
200 /// believing the hand is going to reply at all — a hand that has crashed
201 /// sends nothing, and a tool call that never returns is a daimon that never
202 /// speaks again. It covers the gap between `exec` and `started` ONLY; once a
203 /// command is running the deadline becomes the command's own (see `arm`).
204 var REPLY_GRACE = 30000;
205
206 /// What the page adds to a command's own timeout before giving up on the
207 /// answer. The hand kills at the timeout and then owes an `ended`; this is
208 /// the room that reply is given, and nothing more.
209 var REPLY_SLACK = 30000;
210
211 /// The longest the handshake may take.
212 ///
213 /// Long, because a human sits inside it: opening the port is what puts the
214 /// approval window on screen, and the extension holds the greeting in order
215 /// until it is answered. Bounded all the same, because a question nobody ever
216 /// answers must still end as a refusal the model can act on rather than as a
217 /// tool call that hangs for ever.
218 var HELLO_WAIT = 60000;
219
220 /// How long the extension is given to say where the grant stands.
221 ///
222 /// A question with no window behind it and no host to launch, so it is quick or
223 /// it is not coming. An extension too old to know the command answers nothing
224 /// and this expires, which is the same as it not being there: the page connects
225 /// as it always did.
226 var STATUS_WAIT = 5000;
227
228 /// The extra time given once `hand_status` says the approval window is the thing
229 /// being waited for.
230 ///
231 /// Spent on somebody READING rather than on a program replying, and what they
232 /// are reading is the strongest permission Daimond asks for. Granted once and
233 /// not repeatedly: a question nobody ever answers still has to end as a sentence
234 /// the model can act on.
235 var GRANT_WAIT = 120000;
236
237 /// What a command's timeout is taken to be when the request does not say.
238 /// It mirrors `Tool::RUN_TIMEOUT_DEFAULT_MS`; the page never enforces it, it
239 /// only decides how long to believe in an answer.
240 var TIMEOUT_DEFAULT = 120000;
241
242 /// How long a file operation is given to answer.
243 ///
244 /// The hand gives the fenced child sixty seconds and then stops it, so this is that
245 /// plus room for the round trip: a page that gave up FIRST would report a file
246 /// unchanged while the child was still writing it.
247 var FILE_WAIT = 75000;
248
249 /// The longest the page waits for the hand to say what it is still running.
250 ///
251 /// A measurement, not a command: the hand reaps the groups it knows of and
252 /// walks `/proc` once for each, so the answer is quick or it is not coming.
253 /// A `signal` sent immediately before one is allowed for -- the hand gives a
254 /// group two seconds to take a kill and a quarter of a second to empty
255 /// before it looks -- and the rest is room for a machine under load.
256 var RUNS_WAIT = 20000;
257
258 /// How much of one stream the page keeps, at each end.
259 ///
260 /// A frame is bounded by the wire (`CHUNK_MAX`) and the total was not, so a
261 /// command printing gigabytes — `yes`, a runaway build, a `cat` of a disk
262 /// image — grew an array in the tab until it died. Both ends are kept rather
263 /// than the first: a build says what it is doing at the start and WHY IT
264 /// FAILED at the end, and a cap that keeps only the head throws away the half
265 /// the model needs. What is dropped is stated in the middle, in the stream
266 /// itself, so nobody reads a hole as continuity.
267 var KEEP_HEAD = 256 * 1024;
268 var KEEP_TAIL = 256 * 1024;
269
270 // ── The extension bridge ────────────────────────────────────────
271
272 /// Whether the extension is reachable at all from this page.
273 function hasExt() {
274 return !!(state.extId && window.chrome && chrome.runtime && chrome.runtime.connect);
275 }
276
277 /// Learn the extension's id the way the Web panel learns it.
278 ///
279 /// The extension stamps its own id on `<html>` and fires an event for a page
280 /// that was not listening yet (see `ext/announce.js`). That is the ONE
281 /// discovery mechanism — the page hard-codes no id, so a rebuilt extension
282 /// with a different id still finds its way home, and the ABSENCE of the stamp
283 /// is exactly how we know there is no hand.
284 function detect() {
285 window.addEventListener('daimond-hands', function (e) {
286 var id = e && e.detail && e.detail.id;
287 if (id) state.extId = id;
288 });
289 try { state.extId = document.documentElement.dataset.daimondHands || state.extId; }
290 catch (e) { /* no dataset; the event may still arrive */ }
291 }
292
293 // ── The link ────────────────────────────────────────────────────
294
295 /// Open the one port and greet whatever answers on it.
296 ///
297 /// Resolves once the hand has said hello, which is the only moment the page
298 /// knows the granted root — and therefore the only moment a fence can be
299 /// expressed. Rejects with the sentence the extension gave us, which is
300 /// already written for the model to act on (not installed, declined,
301 /// dismissed, forbidden), or with `NO_HAND` when there is nothing to ask.
302 function open() {
303 if (link && link.greeted) return Promise.resolve(link);
304 if (link) {
305 // A handshake is already in flight; a second caller waits on the
306 // first rather than opening a second port, which would be a second
307 // host process and a second approval window.
308 return new Promise(function (resolve, reject) { link.waiters.push({ resolve: resolve, reject: reject }); });
309 }
310 if (!hasExt()) return Promise.reject(new Error(NO_HAND));
311
312 var rec = { port: null, greeted: false, waiters: [], note: '', timer: null, dead: false };
313 link = rec;
314 var p = new Promise(function (resolve, reject) { rec.waiters.push({ resolve: resolve, reject: reject }); });
315 connect(rec);
316 return p;
317 }
318
319 /// Ask the extension one question, on the message channel rather than the port.
320 ///
321 /// Bounded, because an extension that never answers must still end as a
322 /// sentence rather than a hang; a late answer is dropped, which is the same
323 /// arrangement `web.js` makes with the same channel.
324 ///
325 /// # Arguments
326 /// * `cmd` - The handler's name in `ext/background.js`'s table.
327 /// * `wait` - How long to believe an answer is coming, in milliseconds.
328 function askExt(cmd, wait) {
329 return new Promise(function (resolve) {
330 var done = false;
331 var settle = function (v) { if (!done) { done = true; resolve(v); } };
332 var t = setTimeout(function () { settle(null); }, wait);
333 try {
334 chrome.runtime.sendMessage(state.extId, { cmd: cmd }, function (reply) {
335 clearTimeout(t);
336 // A missing extension, or one too old to know this command, surfaces
337 // here rather than as a throw, and is answered with null: the caller
338 // then behaves exactly as it did before this existed.
339 if (chrome.runtime.lastError || !reply) { settle(null); return; }
340 settle(reply);
341 });
342 } catch (e) { clearTimeout(t); settle(null); }
343 });
344 }
345
346 /// Open the port and say hello on it.
347 ///
348 /// CONNECTING IS WHAT PUTS THE APPROVAL WINDOW ON SCREEN, and that is the right
349 /// way round rather than an accident: by then the hand has spoken, so the window
350 /// can name what this computer can actually enforce and which folder it was
351 /// granted. `hand_grant` would ask before any of that is known, and the window
352 /// then falls back to "did not say what it can limit them to, so treat a command
353 /// as reaching everything you can reach" -- a worse disclosure at the one moment
354 /// that matters. It is not called from here for that reason.
355 function connect(rec) {
356 var port;
357 try { port = chrome.runtime.connect(state.extId, { name: 'daimond-hand' }); }
358 catch (e) { drop(rec, NO_HAND); return; }
359 rec.port = port;
360
361 port.onMessage.addListener(function (msg) { fromHand(rec, msg); });
362 port.onDisconnect.addListener(function () { gone(rec); });
363
364 rec.timer = setTimeout(function () { helloLate(rec); }, HELLO_WAIT);
365
366 try {
367 port.postMessage({ t: 'hello', proto: PROTO, client: deps.client || 'daimond-web' });
368 } catch (e) {
369 drop(rec, NO_HAND);
370 }
371 }
372
373 /// The greeting has not come. Find out WHY before saying anything about it.
374 ///
375 /// The extension holds the greeting behind the approval window, so this deadline
376 /// is spent on a person reading a consent screen as often as on anything going
377 /// wrong -- and until now the two were told apart by guessing. The daimon was
378 /// handed "the approval window may still be waiting" whatever had happened, and
379 /// a person who took longer than a minute to read the strongest permission
380 /// Daimond asks for had their command fail on a question they then said yes to.
381 ///
382 /// `hand_status` answers it: it is a question with no window behind it and no
383 /// host to launch, and it says whether THIS ORIGIN holds the grant. Not granted
384 /// means somebody is still being asked, so the wait is extended once -- bounded,
385 /// because a question nobody ever answers still has to end as a sentence.
386 /// Granted means the window is not the reason and the sentence says so instead
387 /// of blaming a window nobody can see.
388 ///
389 /// An extension too old to know `hand_status` answers nothing, `askExt` resolves
390 /// null, and the sentence is exactly the one that was given before.
391 function helloLate(rec) {
392 if (rec.dead || rec.greeted) return;
393 askExt('hand_status', STATUS_WAIT).then(function (st) {
394 if (rec.dead || rec.greeted) return;
395 var asking = !!(st && st.ok && st.granted === false);
396 if (asking && !rec.waited) {
397 rec.waited = true;
398 rec.timer = setTimeout(function () { helloLate(rec); }, GRANT_WAIT);
399 return;
400 }
401 if (rec.note) { drop(rec, rec.note); return; }
402 drop(rec, asking
403 ? 'The machine hand was asked to start and the approval window has not been '
404 + 'answered. It is still waiting — the Daimond Hands toolbar icon carries the '
405 + 'question until somebody answers it. Ask the user to answer it, and try again.'
406 : 'The machine hand was asked to start and did not answer. It has been allowed on '
407 + 'this computer, so this is not a question waiting to be answered: the hand '
408 + 'itself said nothing. Ask the user to check it is installed and running, and '
409 + 'carry on with the file tools meanwhile.');
410 });
411 }
412
413 /// Settle everyone waiting on the handshake, one way or the other.
414 function greeted(rec) {
415 clearTimeout(rec.timer);
416 rec.greeted = true;
417 var w = rec.waiters;
418 rec.waiters = [];
419 for (var i = 0; i < w.length; i++) w[i].resolve(rec);
420 }
421
422 /// Give up on this link, tell everyone why, and close it.
423 ///
424 /// Once per link, whichever of the three ways it ends: the handshake timing
425 /// out, Chrome reporting the disconnect, or a `postMessage` throwing into a
426 /// port that has already gone. Chrome fires `onDisconnect` a turn AFTER the
427 /// throw, so without the guard the second arrival would tell every
428 /// subscriber the link died twice — and a terminal reads that as two
429 /// endings for one session.
430 function drop(rec, why) {
431 if (rec.dead) return;
432 rec.dead = true;
433 clearTimeout(rec.timer);
434 if (link === rec) link = null;
435 var w = rec.waiters;
436 rec.waiters = [];
437 for (var i = 0; i < w.length; i++) w[i].reject(new Error(why));
438 // Including whoever asked what is still running: their answer is not
439 // coming, and a question left hanging on a dead link is how a caller
440 // waits out a timeout for a sentence it could have had at once.
441 dropRuns(why);
442 // Every run on this link is over, whatever it was doing.
443 for (var id in live) {
444 if (Object.prototype.hasOwnProperty.call(live, id) && live[id].link === rec) {
445 endWith(live[id], why);
446 }
447 }
448 // And every other conversation on it. `met` travels with the sentence
449 // because it is the whole difference between "you have not installed it"
450 // and "it stopped", and the subscriber is as entitled to that as a run is.
451 sayGone(why);
452 if (state.transport !== 'none') forget();
453 try { rec.port.disconnect(); } catch (e) { /* already gone */ }
454 }
455
456 /// Tell every subscriber the link has died, and why.
457 ///
458 /// A handler that throws must not stop the next one being told: the link is
459 /// gone either way, and a renderer's bug is not a reason to leave another
460 /// session waiting for an ending that never comes.
461 function sayGone(why) {
462 var msg = { t: '__gone', message: why || (met ? HAND_GONE : NO_HAND), met: met };
463 for (var id in subs) {
464 if (!Object.prototype.hasOwnProperty.call(subs, id)) continue;
465 var fns = subs[id].slice();
466 for (var i = 0; i < fns.length; i++) {
467 try { fns[i](msg); } catch (e) { /* the subscriber's problem, not the link's */ }
468 }
469 }
470 }
471
472 /// Hand one message to whoever is watching that id, in arrival order.
473 function toSubs(id, msg) {
474 var fns = subs[id];
475 if (!fns) return;
476 fns = fns.slice();
477 for (var i = 0; i < fns.length; i++) {
478 try { fns[i](msg); } catch (e) { /* as above */ }
479 }
480 }
481
482 /// The link went away. This is the ambiguous event, and the page's one job
483 /// here is to be honest about which of the two it was.
484 function gone(rec) {
485 var why = rec.note
486 || (rec.greeted || met ? HAND_GONE : NO_HAND);
487 drop(rec, why);
488 if (deps.note) { try { deps.note(why); } catch (e) {} }
489 }
490
491 /// Hand the oldest outstanding `runs` question its answer, or its refusal.
492 ///
493 /// # Arguments
494 /// * `how` - `'resolve'` or `'reject'`.
495 /// * `value` - The listing, or the sentence a reader acts on.
496 function settleRuns(how, value) {
497 var w = runsWait.shift();
498 if (!w) return false;
499 clearTimeout(w.timer);
500 if (how === 'resolve') w.resolve(value); else w.reject(new Error(value));
501 return true;
502 }
503
504 /// Settle every outstanding `runs` question at once, for a link that died.
505 function dropRuns(why) {
506 while (settleRuns('reject', why)) { /* until the queue is empty */ }
507 // The folder browser's waiters go with them: a question left hanging on a dead link is
508 // how a caller waits out a timeout for a sentence it could have had at once.
509 while (dirsWait.length) {
510 var dq = dirsWait.shift();
511 if (dq.timer) clearTimeout(dq.timer);
512 try { dq.reject(new Error(why)); } catch (e) { /* the caller's problem */ }
513 }
514 while (grantWait.length) {
515 var gq = grantWait.shift();
516 if (gq.timer) clearTimeout(gq.timer);
517 try { gq.reject(new Error(why)); } catch (e) { /* the caller's problem */ }
518 }
519 }
520
521 // ── What the hand says ──────────────────────────────────────────
522
523 /// One message from the hand, routed to whoever it is about.
524 ///
525 /// Only a RECOGNISED type does anything at all. A message the page does not
526 /// understand is not evidence that the hand is alive and well — a hostile or
527 /// broken host sending `{"t":"noop"}` every 700 ms used to hold a promise
528 /// open for ever, because the grace timer was refreshed before the type was
529 /// looked at (§4.2). Unknown types now touch no timer and no run.
530 function fromHand(rec, msg) {
531 if (!msg || typeof msg.t !== 'string') return;
532
533 if (msg.t === 'hello') {
534 adopt(msg);
535 greeted(rec);
536 return;
537 }
538 // The two the reload grace produces. Neither carries an id and neither is
539 // about a run of this page's, so both are taken above the run switch for
540 // the same reason `runs` is.
541 if (msg.t === 'resumed') { resumed(msg); return; }
542 if (msg.t === 'lapsed') { lapsed(msg); return; }
543 // What the hand is still running. No id, by design (see `runsWait`), so it
544 // is taken HERE, above the run switch that would otherwise drop it.
545 // The folder browser's answer. Settled oldest-first exactly as `runs` is, and for the
546 // same reason: the message carries no id, because there is nothing about it that a
547 // second browser window could confuse with the first.
548 // The grant's answer. One outstanding at a time, like the walk's.
549 if (msg.t === 'granted') {
550 var gw = grantWait.shift();
551 if (gw) {
552 if (gw.timer) clearTimeout(gw.timer);
553 gw.resolve({ path: String(msg.path || ''), note: String(msg.note || '') });
554 }
555 return;
556 }
557 if (msg.t === 'dirs') {
558 var dw = dirsWait.shift();
559 if (dw) {
560 if (dw.timer) clearTimeout(dw.timer);
561 dw.resolve({
562 path: String(msg.path || ''),
563 up: String(msg.up || ''),
564 dirs: Array.isArray(msg.dirs) ? msg.dirs : [],
565 roots: Array.isArray(msg.roots) ? msg.roots : [],
566 });
567 }
568 return;
569 }
570 if (msg.t === 'runs') {
571 var news = gapNews;
572 gapNews = '';
573 settleRuns('resolve', {
574 runs: Array.isArray(msg.runs) ? msg.runs : [],
575 more: Number(msg.more) || 0,
576 // The two the grace adds. `carried` names WHICH runs left output
577 // behind and how much, so the listing can point at it without
578 // putting a build's whole output in every answer.
579 note: news,
580 carried: Object.keys(carried).map(function (id) {
581 return { id: id, bytes: carried[id].bytes, ended: !!carried[id].ended };
582 }),
583 lost: carriedLost,
584 });
585 return;
586 }
587 // A connection-level error — no id — is the extension speaking about the
588 // link itself: not installed, declined, dismissed, forbidden, or the host
589 // disconnecting. It is written for the model, so it is kept verbatim and
590 // used as the reason when the port closes a moment later.
591 //
592 // It is ALSO how the hand answers a `runs` it could not carry out, and
593 // the two are not distinguishable from here. Both mean the same thing to
594 // whoever is waiting — no listing is coming — so the oldest waiter is
595 // settled with the sentence either way, and the note is still kept.
596 if (msg.t === 'error' && !msg.id) {
597 rec.note = msg.message || rec.note;
598 settleRuns('reject', msg.message || (met ? HAND_GONE : NO_HAND));
599 if (!rec.greeted) drop(rec, rec.note || NO_HAND);
600 return;
601 }
602
603 // A conversation this file does not carry itself — a terminal session —
604 // is handed on whole, before the run switch below looks at the type. It
605 // has to be: `opened`, `output` and `closed` mean nothing to a run, and a
606 // relay that only forwarded what it understood would be a second, older
607 // opinion about what the wire says.
608 if (msg.id) toSubs(msg.id, msg);
609
610 var run = msg.id ? live[msg.id] : null;
611 if (!run) {
612 // About a run that is over, or one that was never ours -- which after
613 // a reload is exactly the shape of a run THIS PAGE INHERITED. Kept
614 // rather than dropped; see the note on `carried`.
615 carry(msg);
616 return;
617 }
618
619 if (msg.t === 'started') {
620 // The one timer change there is: the wait stops being "acknowledge
621 // me" and becomes the command's own limit. A quiet command is no
622 // longer a dead one (§4.3) — a `cargo test` that says nothing for
623 // four minutes is the case this file exists for.
624 run.started = true;
625 arm(run, run.limit + REPLY_SLACK, 'The machine hand started the command and never said '
626 + 'how it ended, past the time the command itself was given. Treat the result as unknown.');
627 if (deps.onStart) { try { deps.onStart(msg.id, msg.pid); } catch (e) {} }
628 return;
629 }
630 if (msg.t === 'chunk') { absorb(run, msg); return; }
631 if (msg.t === 'error') {
632 // An error ABOUT a run is a note, not an ending. The extension
633 // reports a gap in the sequence this way and then carries on
634 // sending the rest of the output, so treating it as a settlement
635 // threw away a run that was going to finish.
636 note(run, msg.message || 'The machine hand reported a problem with this run.');
637 return;
638 }
639 if (msg.t === 'refused') {
640 settle(run, 'resolve', JSON.stringify({ refused: msg.reason }));
641 return;
642 }
643 // A file operation has one answer and no lifecycle: no `started`, no chunks, no
644 // `ended`. It settles the wait outright, which is why `file` below arms one timer
645 // rather than the two a command needs.
646 if (msg.t === 'filed') {
647 settle(run, 'resolve', JSON.stringify({ ok: !!msg.ok, text: String(msg.text || '') }));
648 return;
649 }
650 if (msg.t === 'ended') {
651 if (deps.onEnd) { try { deps.onEnd(msg.id, msg.exit); } catch (e) {} }
652 settle(run, 'resolve', JSON.stringify({
653 exit: msg.exit,
654 timed_out: !!msg.timed_out,
655 killed: !!msg.killed,
656 stdout: text(run.out),
657 stderr: text(run.err),
658 out_bytes: msg.out_bytes,
659 err_bytes: msg.err_bytes,
660 note: run.note,
661 }));
662 }
663 }
664
665 /// Keep one message about a run this page did not start.
666 ///
667 /// Only output and endings: an error or a refusal about a stranger's run
668 /// says nothing a reader can act on without the output it belongs to.
669 function carry(msg) {
670 if (!msg.id) return;
671 if (msg.t !== 'chunk' && msg.t !== 'ended') return;
672 var rec = carried[msg.id];
673 if (!rec) { rec = carried[msg.id] = { out: [], err: [], bytes: 0, ended: null }; }
674 if (msg.t === 'ended') {
675 rec.ended = { exit: msg.exit, killed: !!msg.killed, timed_out: !!msg.timed_out };
676 return;
677 }
678 var d = String(msg.data || '');
679 if (!d) return;
680 (msg.stream === 'err' ? rec.err : rec.out).push(d);
681 rec.bytes += d.length;
682 carriedBytes += d.length;
683 // Over the bound the OLDEST goes, and it is counted. The end of a build
684 // is what a reader wants; the beginning is what they already saw.
685 while (carriedBytes > CARRIED_MAX) {
686 var ids = Object.keys(carried);
687 if (!ids.length) break;
688 var oldest = carried[ids[0]];
689 var gone = oldest.out.length ? oldest.out.shift() : oldest.err.shift();
690 if (gone === undefined) { delete carried[ids[0]]; continue; }
691 oldest.bytes -= gone.length;
692 carriedBytes -= gone.length;
693 carriedLost += gone.length;
694 }
695 }
696
697 /// This page has taken over a hand that was held for it across a reload.
698 ///
699 /// The sentence says how long the gap was and whether anything was let go in
700 /// it, because a re-attach that is short of output has to say how short. What
701 /// follows this message is the replay, and it lands in `carried`.
702 function resumed(msg) {
703 var away = Math.round(Math.max(0, Number(msg.away_ms) || 0) / 1000);
704 var ids = Array.isArray(msg.ids) ? msg.ids.filter(function (x) { return typeof x === 'string' && x; }) : [];
705 var lost = Number(msg.dropped) || 0;
706 gapNews = 'This page reloaded and picked the machine hand back up after ' + away
707 + ' second(s). Nothing it was running was stopped'
708 + (ids.length ? ', and ' + ids.join(', ') + ' ' + (ids.length === 1 ? 'was' : 'were') + ' still going' : '')
709 + '. Output that arrived while the page was away was held and is readable '
710 + 'with runs and a "read".'
711 + (lost ? ' ' + lost + ' message(s) of it were let go to keep the hold bounded, so '
712 + 'the held output starts part way through.' : '');
713 if (deps.note) { try { deps.note(gapNews); } catch (e) {} }
714 }
715
716 /// This page arrived after the hold had run out.
717 ///
718 /// The one sentence this whole mechanism owes a reader: something was stopped,
719 /// when, and what. A process killed at thirty seconds must SAY so, on the same
720 /// rule that made the teardown report a failed kill instead of swallowing it.
721 function lapsed(msg) {
722 var hold = Math.round(Math.max(0, Number(msg.hold_ms) || 0) / 1000);
723 var ids = Array.isArray(msg.ids) ? msg.ids.filter(function (x) { return typeof x === 'string' && x; }) : [];
724 gapNews = 'The page before this one went away and did not come back within ' + hold
725 + ' seconds, so everything the machine hand was running for it was STOPPED'
726 + (msg.why ? ' (' + msg.why + ')' : '')
727 + '. ' + (ids.length
728 ? ids.length + ' were stopped: ' + ids.join(', ') + '.'
729 : (msg.unknown
730 ? 'The hand did not answer in time, so what was stopped is not known here.'
731 : 'Nothing was still running by then.'))
732 + ' Anything the user needs is not running now, so start it again rather than '
733 + 'assuming it is there.';
734 if (deps.note) { try { deps.note(gapNews); } catch (e) {} }
735 }
736
737 /// Record what a paired hand told us about itself, from its `hello`.
738 ///
739 /// `caps` is a list rather than a version number on purpose: the fence lands
740 /// on one platform before another, so the app must be able to say WHICH
741 /// guarantee it is offering on this machine. A hand that cannot fence is not
742 /// dressed up as one that can.
743 function adopt(hello) {
744 if (!hello) { return; }
745 state.transport = hello.transport || 'machine';
746 state.machine = hello.host || '';
747 state.version = hello.version || '';
748 state.os = hello.os || '';
749 state.caps = Array.isArray(hello.caps) ? hello.caps.slice() : [];
750 state.root = hello.root || rootCap(state.caps);
751 // A hand that reconnects may be in a different folder, or the same folder with a new
752 // identity — which means somebody deleted the file. Either way the last answer is about
753 // something else now, so it is dropped rather than reused.
754 wsProof = null;
755 met = true;
756 }
757
758 /// The granted root, as the `caps` list carries it. See `ROOT_CAP`.
759 function rootCap(caps) {
760 return capValue(caps, ROOT_CAP);
761 }
762
763 /// The value of a `key:<value>` capability entry, or '' where the hand sent none.
764 ///
765 /// # Arguments
766 /// * `caps` - What the hand reported in its `hello`.
767 /// * `prefix` - The entry's name, colon included.
768 function capValue(caps, prefix) {
769 for (var i = 0; i < caps.length; i++) {
770 if (typeof caps[i] === 'string' && caps[i].indexOf(prefix) === 0) {
771 return caps[i].slice(prefix.length);
772 }
773 }
774 return '';
775 }
776
777 // ── Is the hand's folder the folder this page is looking at? ────
778 //
779 // `hand/REVIEW.md` §1.14. Every workspace-relative path a command is fenced by is joined onto
780 // the folder the hand reports, and nothing checked that the two ends meant the same folder.
781 // With an OPFS-only workspace, or an FSA folder that is not the grant, the fence names paths
782 // on the machine that have nothing to do with the files the model just read — and the command
783 // runs, correctly fenced, against the wrong files.
784 //
785 // The hand holds one of the two names and can only supply evidence. The comparison belongs
786 // where both names meet, which is here — and it REFUSES, in `settled()`, rather than merely
787 // reporting what it found.
788
789 /// The last comparison, cached per grant and per folder: `{ key, dir, ok, why }`.
790 ///
791 /// Keyed by the root and the token together, so a hand that reconnects to a different folder
792 /// — or the same folder with a new identity, which means somebody deleted the file — is asked
793 /// again rather than believed on the strength of an older answer.
794 ///
795 /// The HANDLE is part of the key too, and that half is what makes the cache safe to arm on. A
796 /// user who opens a different folder in the Workspace panel changes one of the two names being
797 /// compared while the hand says nothing at all, and a verdict remembered by grant alone would
798 /// answer for a folder it never read — passing a swap in the direction that runs the command.
799 var wsProof = null;
800
801 /// Whether this page's folder is the folder the hand was granted, and what to say if not.
802 ///
803 /// Four outcomes, and the ordinary one is silent. It has to be: a check that puts a sentence
804 /// in front of somebody every time it passes is a check people learn to dismiss.
805 ///
806 /// The folder's NAME is never compared. Two projects called `site` on one machine is the
807 /// ordinary case, not the exotic one, and a check that passes for the wrong folder is worse
808 /// than no check at all.
809 ///
810 /// # Returns
811 /// `{ ok: true }`, or `{ ok: false, why: '<the sentence the model and the user read>' }`.
812 async function proveFolder() {
813 var token = capValue(state.caps, WS_CAP);
814 var key = state.root + '\u0000' + token;
815 var dir = (deps.folder && deps.folder()) || null;
816 if (wsProof && wsProof.key === key && wsProof.dir === dir) return wsProof;
817
818 var out = await folderVerdict(token, dir);
819 wsProof = { key: key, dir: dir, ok: out.ok, why: out.why || '' };
820 return wsProof;
821 }
822
823 /// The verdict itself, without the caching.
824 ///
825 /// # Arguments
826 /// * `token` - The `ws:` value the hand published, or '' where it published none.
827 /// * `dir` - The folder the page has open, or null where it has none.
828 async function folderVerdict(token, dir) {
829 if (!token) {
830 // A hand that says nothing about its folder, which is a hand from before the token
831 // existed. §1.14 enumerates four outcomes and every one of them presupposes a `ws:`
832 // value, so this is not one of them, and refusing here would be this file inventing a
833 // fifth rule rather than implementing the four.
834 //
835 // **It is a compatibility seam and it is recorded as one.** A hand this old cannot be
836 // checked at all, so a page that meets one is back where it was before §1.14: it will
837 // run a command in whatever folder the hand names. The reason not to close it here is
838 // that a page cannot tell an old hand from any other silent thing on that wire — a
839 // mock, a different implementation — and the closing move belongs at the protocol
840 // version, where "this hand is too old to serve" can be said once and plainly, rather
841 // than as a folder complaint the user cannot act on.
842 return { ok: true, why: '' };
843 }
844 if (token === WS_UNPROVEN) {
845 return { ok: false, why: 'The machine hand could not write its identity file into the '
846 + 'folder it was granted, so the two ends cannot confirm they mean the same '
847 + 'folder. The hand\'s own error output names the path that failed.' };
848 }
849 if (!dir) {
850 // Its own case, and NOT one to skip for want of a handle. There is nothing to read
851 // the token through, so the check cannot pass — and a check that is skipped when it
852 // cannot pass is not a check.
853 return { ok: false, why: 'This workspace lives in the browser and not in a folder on '
854 + 'this machine, so there is nothing for the hand\'s commands to run against. Open '
855 + 'a folder for this workspace before using the machine hand.' };
856 }
857 var mine = '';
858 try {
859 // No `{create: true}`, on either call. A page that creates the file proves nothing:
860 // it would be comparing a token it had just written with one it was given.
861 var sub = await dir.getDirectoryHandle(WS_DIR);
862 var file = await sub.getFileHandle(WS_FILE);
863 mine = await (await file.getFile()).text();
864 } catch (e) {
865 return { ok: false, why: mismatch(dir) };
866 }
867 if (firstLine(mine) !== token) return { ok: false, why: mismatch(dir) };
868 return { ok: true, why: '' };
869 }
870
871 /// The identity a `workspace.id` carries: the first line that is neither blank nor a comment.
872 ///
873 /// The file opens with four comment lines explaining itself to whoever finds it, so the token
874 /// is not simply the first line.
875 ///
876 /// # Arguments
877 /// * `text` - The file's whole contents.
878 function firstLine(text) {
879 var lines = String(text || '').split(/\r?\n/);
880 for (var i = 0; i < lines.length; i++) {
881 var line = lines[i].trim();
882 if (!line || line.charAt(0) === '#') continue;
883 return line;
884 }
885 return '';
886 }
887
888 /// The sentence for a folder that is not the granted one, or has no identity in it.
889 ///
890 /// Both ends are named, because the user cannot otherwise tell which one is wrong — and the
891 /// two fixes are different: change `root.txt`, or open the other folder here.
892 ///
893 /// # Arguments
894 /// * `dir` - The directory handle the page holds.
895 function mismatch(dir) {
896 return 'The folder you opened in Daimond is not the folder the machine hand was told to '
897 + 'work in, so a command would run against different files from the ones Daimond has '
898 + 'been reading. Daimond has \u201c' + ((dir && dir.name) || 'this folder')
899 + '\u201d; the hand has \u201c' + (state.root || 'nowhere named')
900 + '\u201d. Fix the path in the hand\'s root.txt, or open the other folder here.';
901 }
902
903 // ── Output ──────────────────────────────────────────────────────
904
905 /// A bounded accumulator for one stream: a head, a tail, and the truth about
906 /// what fell between them.
907 function stream() {
908 return { head: [], headLen: 0, tail: [], tailLen: 0, dropped: 0 };
909 }
910
911 /// Split a string at `n` UTF-16 units without cutting a surrogate pair in
912 /// half, which would leave a lone surrogate in the model's transcript.
913 function cut(s, n) {
914 if (n >= s.length) return s.length;
915 var c = s.charCodeAt(n - 1);
916 return (c >= 0xD800 && c <= 0xDBFF) ? n - 1 : n;
917 }
918
919 /// Keep what we can of one run of output, and count what we cannot.
920 function keep(st, data) {
921 var s = String(data == null ? '' : data);
922 if (!s) return;
923 if (st.headLen < KEEP_HEAD) {
924 var room = KEEP_HEAD - st.headLen;
925 if (s.length <= room) { st.head.push(s); st.headLen += s.length; return; }
926 var at = cut(s, room);
927 if (at > 0) { st.head.push(s.slice(0, at)); st.headLen += at; }
928 s = s.slice(at);
929 }
930 st.tail.push(s);
931 st.tailLen += s.length;
932 while (st.tailLen > KEEP_TAIL && st.tail.length > 1) {
933 var old = st.tail.shift();
934 st.tailLen -= old.length;
935 st.dropped += old.length;
936 }
937 }
938
939 /// One stream as the model reads it, with any hole named where it happened.
940 function text(st) {
941 if (!st.dropped) return st.head.join('') + st.tail.join('');
942 return st.head.join('')
943 + '\n[… ' + st.dropped + ' characters of output are missing here: the command printed '
944 + 'more than the page will hold, so the middle was dropped and the two ends kept …]\n'
945 + st.tail.join('');
946 }
947
948 /// Accumulate a chunk, in order, and pass it to whoever is drawing.
949 ///
950 /// A gap in `seq` is SURFACED rather than hidden. Silently stitching over a
951 /// missing chunk would hand the daimon output that never existed, and a
952 /// build log with a hole in it is worse than one that says it has a hole.
953 ///
954 /// The FIRST chunk of a stream sets the baseline — where the hand starts
955 /// counting is the hand's business, and the extension's own check says the
956 /// same. Assuming zero made every run open with a hole it did not have,
957 /// which is the same failure as hiding one: the marker stops meaning
958 /// anything.
959 function absorb(run, msg) {
960 var s = msg.stream === 'err' ? 'err' : 'out';
961 var st = run[s];
962 var want = run.seq[s];
963 if (want !== null && msg.seq !== want) {
964 keep(st, '\n[output missing: expected chunk ' + want + ', got ' + msg.seq + ']\n');
965 run.gap = true;
966 }
967 run.seq[s] = msg.seq + 1;
968 keep(st, msg.data);
969 if (deps.onChunk) { try { deps.onChunk(msg.id, s, msg.data); } catch (e) {} }
970 }
971
972 // ── Running a command ───────────────────────────────────────────
973
974 /// Set the deadline for this run, replacing whatever it had.
975 function arm(run, ms, why) {
976 clearTimeout(run.timer);
977 run.timer = setTimeout(function () { endWith(run, why); }, ms);
978 }
979
980 /// Add to what the page has to say about this run, over and above what the
981 /// command printed. It travels in `note`, never in `stdout`: a page's account
982 /// of a broken link dressed as a program's output is a model debugging the
983 /// wrong thing.
984 function note(run, line) {
985 if (!line) return;
986 run.note = run.note ? (run.note + ' ' + line) : line;
987 }
988
989 /// End a run that will not answer, with the sentence saying why.
990 function endWith(run, why) {
991 note(run, why);
992 settle(run, 'reject', new Error(run.note));
993 }
994
995 /// Finish a run once, whichever way it finished.
996 function settle(run, how, value) {
997 if (run.done) return;
998 run.done = true;
999 clearTimeout(run.timer);
1000 delete live[run.id];
1001 if (how === 'resolve') run.resolve(value); else run.reject(value);
1002 }
1003
1004 /// Run one command. `specJson` is the wire's own `exec` request, built by the
1005 /// wasm side; this function does not interpret it beyond reading the id and
1006 /// the timeout, so there is one place the request is composed and it is the
1007 /// one that holds the fence.
1008 function run(specJson) {
1009 var spec;
1010 try { spec = JSON.parse(specJson); }
1011 catch (e) { return Promise.reject(new Error('The command could not be read: ' + e.message)); }
1012
1013 return open().then(function (rec) {
1014 return new Promise(function (resolve, reject) {
1015 var id = spec.id || 'run';
1016 var r = {
1017 id: id,
1018 link: rec,
1019 resolve: resolve,
1020 reject: reject,
1021 seq: { out: null, err: null }, // set by the first chunk of each stream
1022 out: stream(),
1023 err: stream(),
1024 gap: false,
1025 note: '',
1026 started: false,
1027 done: false,
1028 timer: null,
1029 limit: Number(spec.timeout_ms) > 0 ? Number(spec.timeout_ms) : TIMEOUT_DEFAULT,
1030 };
1031 live[id] = r;
1032 // Until the hand says it has started the command, this is the
1033 // wait. After that it is the command's own (see `fromHand`).
1034 arm(r, REPLY_GRACE, 'The machine hand did not acknowledge the command. It may have '
1035 + 'stopped; ask the user to check it is still running.');
1036 try { rec.port.postMessage(spec); }
1037 catch (e) { endWith(r, met ? HAND_GONE : NO_HAND); }
1038 });
1039 });
1040 }
1041
1042 /// Change or read one file on the machine. `specJson` is the wire's own `file`
1043 /// request, built by the wasm side, and this function interprets it no further than
1044 /// `run` interprets an `exec`: the request is composed in the one place that holds
1045 /// the fence, and the relay carries it.
1046 ///
1047 /// One timer, not two. A command announces itself with `started` and then runs for as
1048 /// long as it was given, so its wait changes shape half-way; a file operation answers
1049 /// once or not at all.
1050 function file(specJson) {
1051 var spec;
1052 try { spec = JSON.parse(specJson); }
1053 catch (e) {
1054 return Promise.reject(new Error('The file request could not be read: ' + e.message));
1055 }
1056
1057 return open().then(function (rec) {
1058 return new Promise(function (resolve, reject) {
1059 var id = spec.id || 'file';
1060 var r = {
1061 id: id,
1062 link: rec,
1063 resolve: resolve,
1064 reject: reject,
1065 seq: { out: null, err: null },
1066 out: stream(),
1067 err: stream(),
1068 gap: false,
1069 note: '',
1070 started: true,
1071 done: false,
1072 timer: null,
1073 limit: FILE_WAIT,
1074 };
1075 live[id] = r;
1076 arm(r, FILE_WAIT, 'The machine hand did not answer a file request. It may have '
1077 + 'stopped; ask the user to check it is still running. Do not assume the file '
1078 + 'is unchanged.');
1079 try { rec.port.postMessage(spec); }
1080 catch (e) { endWith(r, met ? HAND_GONE : NO_HAND); }
1081 });
1082 });
1083 }
1084
1085 // ── One message, on the one link ────────────────────────────────
1086 //
1087 // `run` is the shape a command has: send one thing, wait for its whole
1088 // result. A terminal is the other shape — bytes both ways, for as long as
1089 // the program lives — and it needs the link rather than a second copy of the
1090 // wire. So these two are the whole of what js/handpty.js asks for, and they
1091 // share `open`, the port and the greeting with `run`. Opening a second port
1092 // would start a second host process and ask a second approval question for a
1093 // hand the user granted once.
1094
1095 /// Post one wire message on the one link, opening and greeting it first if
1096 /// need be.
1097 ///
1098 /// # Arguments
1099 /// * `msg` - The wire message, already composed by whoever holds the fence.
1100 ///
1101 /// # Returns
1102 /// A promise that resolves when the message has been handed to the
1103 /// extension, and rejects with the sentence a reader acts on — not
1104 /// installed, declined, stopped part-way — verbatim.
1105 function send(msg) {
1106 return open().then(function (rec) {
1107 // The port may have died between the handshake and here, and Chrome
1108 // reports that a turn LATE: `postMessage` throws "Attempting to use a
1109 // disconnected port object" while `onDisconnect` has not yet run, so
1110 // `link` still points at a corpse. Both halves are handled here rather
1111 // than waited on, because a caller is owed an answer now.
1112 if (rec.dead || link !== rec) {
1113 return Promise.reject(new Error(rec.note || (met ? HAND_GONE : NO_HAND)));
1114 }
1115 try {
1116 rec.port.postMessage(msg);
1117 } catch (e) {
1118 var why = rec.note || (met ? HAND_GONE : NO_HAND);
1119 // Ends the link ONCE, for everybody: the runs on it, the callers
1120 // waiting on it, and every subscriber. Chrome's own disconnect
1121 // arrives afterwards and finds it already settled.
1122 drop(rec, why);
1123 return Promise.reject(new Error(why));
1124 }
1125 return undefined;
1126 });
1127 }
1128
1129 // ── What is still running, and stopping it ─────────────────────
1130 //
1131 // A command may outlive itself: `bash dev/world.sh 3 --up` starts a server
1132 // and exits, the direct child is reaped, and the process GROUP goes on
1133 // holding a port. Nothing else on the machine can reach it -- the fence
1134 // scopes signals to the Landlock domain that sent them, so a LATER command's
1135 // `kill` answers "Operation not permitted", and `/proc` is outside the fence
1136 // so the pid cannot even be found. The hand is not the fenced thing, so the
1137 // hand can; these two are how the page asks it to.
1138 //
1139 // There is deliberately no "stopped" answer to a signal, so `signal` resolves
1140 // when the message has been HANDED OVER and promises nothing about the
1141 // process. Whether it took is a question for `runs`, which measures.
1142
1143 /// Ask the hand what it is still running, standing groups included.
1144 ///
1145 /// # Returns
1146 /// A promise for `{ runs: [{ id, pid, what, state, secs }], more }`, or a
1147 /// rejection carrying the sentence a reader acts on.
1148 function runs() {
1149 return send({ t: 'runs' }).then(function () {
1150 return new Promise(function (resolve, reject) {
1151 var w = { resolve: resolve, reject: reject, timer: null };
1152 w.timer = setTimeout(function () {
1153 // Taken out of the queue by hand rather than through
1154 // `settleRuns`, which settles the OLDEST: this one timed out
1155 // and an answer arriving late belongs to whoever is still
1156 // waiting, not to a caller who has already given up.
1157 var i = runsWait.indexOf(w);
1158 if (i < 0) return;
1159 runsWait.splice(i, 1);
1160 reject(new Error('The machine hand did not say what it is still running within '
1161 + Math.round(RUNS_WAIT / 1000) + ' seconds. It may have stopped; ask the user '
1162 + 'to check it is still there.'));
1163 }, RUNS_WAIT);
1164 runsWait.push(w);
1165 });
1166 });
1167 }
1168
1169 /// The directories inside `path`, so a person can CHOOSE a folder and be given its real
1170 /// path.
1171 ///
1172 /// The browser's own `showDirectoryPicker` cannot serve this -- it answers with a handle
1173 /// carrying a name and no path -- so the only end that can offer a folder chooser is the
1174 /// hand, which is on the machine. Bounded there to what it would fence a terminal to.
1175 ///
1176 /// # Arguments
1177 /// * `path` - Absolute, or '' to ask where this hand will start from.
1178 ///
1179 /// # Returns
1180 /// A promise for `{ path, up, dirs, roots }`.
1181 function dirs(path) {
1182 return send({ t: 'dirs', path: String(path || '') }).then(function () {
1183 return new Promise(function (resolve, reject) {
1184 var w = { resolve: resolve, reject: reject, timer: null };
1185 w.timer = setTimeout(function () {
1186 var i = dirsWait.indexOf(w);
1187 if (i < 0) return;
1188 dirsWait.splice(i, 1);
1189 reject(new Error('The machine hand did not answer with a folder listing. It may '
1190 + 'have stopped; ask the user to check it is still there.'));
1191 }, RUNS_WAIT);
1192 dirsWait.push(w);
1193 });
1194 });
1195 }
1196
1197 /// Record the folder this hand may work in, having walked to it with `dirs`.
1198 ///
1199 /// The page proposes and the HAND decides: it refuses `/`, anything that is not a
1200 /// directory, and any folder containing its own record. It takes effect when the hand
1201 /// next starts, and the answer says so.
1202 ///
1203 /// # Arguments
1204 /// * `path` - Absolute path to the folder.
1205 ///
1206 /// # Returns
1207 /// A promise for `{ path, note }`, or a rejection carrying the hand's own sentence.
1208 function grant(path) {
1209 return send({ t: 'grant', path: String(path || '') }).then(function () {
1210 return new Promise(function (resolve, reject) {
1211 var w = { resolve: resolve, reject: reject, timer: null };
1212 w.timer = setTimeout(function () {
1213 var i = grantWait.indexOf(w);
1214 if (i < 0) return;
1215 grantWait.splice(i, 1);
1216 reject(new Error('The machine hand did not say whether it wrote the folder down.'));
1217 }, RUNS_WAIT);
1218 grantWait.push(w);
1219 });
1220 });
1221 }
1222
1223 /// Hand over the output kept for one run this page inherited across a reload.
1224 ///
1225 /// SPENT ON READING. What is handed over is dropped here, because the whole
1226 /// of it has gone to the reader and a second copy in a tab is a second copy of
1227 /// a build's output nobody will ever look at again. A run that is still going
1228 /// goes on collecting after this.
1229 ///
1230 /// # Arguments
1231 /// * `id` - The run's identifier, as the listing carries it.
1232 ///
1233 /// # Returns
1234 /// `{ found, out, err, bytes, ended, exit }`, never a rejection: a reader
1235 /// asking about an id nothing was held for is owed an answer, not an error.
1236 /// `ended` is flat rather than an object, so the Rust side reads it with the
1237 /// same two extractors it reads everything else with.
1238 function held(id) {
1239 var rec = carried[String(id || '')];
1240 if (!rec) {
1241 return Promise.resolve({ found: false, out: '', err: '', bytes: 0, ended: false, exit: 0 });
1242 }
1243 delete carried[String(id || '')];
1244 carriedBytes = Math.max(0, carriedBytes - rec.bytes);
1245 return Promise.resolve({
1246 found: true,
1247 out: rec.out.join(''),
1248 err: rec.err.join(''),
1249 bytes: rec.bytes,
1250 ended: !!rec.ended,
1251 exit: rec.ended ? rec.ended.exit : 0,
1252 });
1253 }
1254
1255 /// Signal one run this hand started, by the identifier it was given.
1256 ///
1257 /// # Arguments
1258 /// * `id` - The identifier the run was given at `exec`. Never a pid, never a
1259 /// name and never a pattern: only a group this hand's own launcher created
1260 /// can be named at all, and that is the whole of the guard.
1261 /// * `sig` - `'term'`, `'kill'` or `'int'`.
1262 function signal(id, sig) {
1263 if (!id || typeof id !== 'string') {
1264 return Promise.reject(new Error('A signal needs the identifier of the run it is for.'));
1265 }
1266 var which = (sig === 'kill' || sig === 'int') ? sig : 'term';
1267 return send({ t: 'signal', id: id, sig: which });
1268 }
1269
1270 /// Watch everything the hand says about one id.
1271 ///
1272 /// # Arguments
1273 /// * `id` - The identifier the wire carries on every message about it.
1274 /// * `fn` - Called with each message, in arrival order, and with
1275 /// `{t:'__gone', message, met}` when the link dies.
1276 ///
1277 /// # Returns
1278 /// A function that stops the watching. It does not end whatever is being
1279 /// watched: a terminal is the hand's until the link closes.
1280 function subscribe(id, fn) {
1281 if (!id || typeof fn !== 'function') return function () {};
1282 if (!subs[id]) subs[id] = [];
1283 subs[id].push(fn);
1284 return function () {
1285 var a = subs[id];
1286 if (!a) return;
1287 var i = a.indexOf(fn);
1288 if (i >= 0) a.splice(i, 1);
1289 if (!a.length) delete subs[id];
1290 };
1291 }
1292
1293 // ── What the hand is ────────────────────────────────────────────
1294
1295 /// Ask the hand who and where it is, greeting it first if need be.
1296 ///
1297 /// The `root` it reports is load-bearing: the page CANNOT know a real
1298 /// folder's path, because the File System Access API hands over a handle and
1299 /// never a path, so the fence the wasm side builds is expressed against
1300 /// whatever the hand says its grant covers. A hand that will not say has to
1301 /// be treated as no hand at all — guessing a root would be guessing what a
1302 /// command may touch.
1303 ///
1304 /// It never rejects. A caller asking what is attached is owed an answer, and
1305 /// `reason` carries the sentence the model should read when the answer is
1306 /// "nothing" — which is otherwise lost, and was: every failure to pair, for
1307 /// whatever cause, came out as the same "it did not say which folder".
1308 function status() {
1309 if (state.transport !== 'none' && state.root) return settled();
1310 if (!hasExt()) {
1311 return Promise.resolve(JSON.stringify({
1312 paired: false, transport: 'none', caps: [], reason: NO_HAND,
1313 }));
1314 }
1315 return open().then(function () {
1316 if (!state.root) {
1317 return JSON.stringify({
1318 paired: false, transport: state.transport, caps: state.caps,
1319 machine: state.machine,
1320 reason: 'The machine hand answered but did not say which folder it was granted, '
1321 + 'so there is no way to say what a command may touch. It is not safe to '
1322 + 'guess. Ask the user to re-run the hand\'s installer, and carry on with '
1323 + 'the file tools meanwhile.',
1324 });
1325 }
1326 return settled();
1327 }, function (e) {
1328 return JSON.stringify({
1329 paired: false, transport: 'none', caps: [],
1330 reason: (e && e.message) || NO_HAND,
1331 });
1332 });
1333 }
1334
1335 /// What we know about the hand, with the folder comparison made and ACTED ON.
1336 ///
1337 /// The comparison is made HERE, at the door every caller already goes through, rather than
1338 /// beside each of them: `Tool::run` reads this, and so does the terminal.
1339 ///
1340 /// # The refusal
1341 ///
1342 /// `hand/REVIEW.md` §1.14. A folder that cannot be shown to be this page's folder makes this
1343 /// answer `paired: false`, and `reason` carries the sentence §1.14 wrote for whichever of the
1344 /// four outcomes was reached. Every route to a command reads this first — `Tool::run` refuses
1345 /// on `paired`, `pty_request` refuses on `paired`, and the Terminal panel shows `reason` — so
1346 /// one refusal here closes all of them. The ORDINARY case is silent: an equal token adds
1347 /// `workspace: 'ok'` and nothing else, because a check that speaks every time it passes is a
1348 /// check people learn to dismiss.
1349 ///
1350 /// What the hand said about itself is kept on the refusal — `root`, `caps`, `os` — because it
1351 /// is true and useful: the hand really did report those, and what is refused is that they
1352 /// describe the folder this page has open. Nothing composes a fence from it: every caller
1353 /// gates on `paired` before reading a root.
1354 ///
1355 /// **No automated test can satisfy this check**, and that is structural rather than a gap in
1356 /// the tests. A page holds a real folder only through `showDirectoryPicker()`, a native dialog
1357 /// no harness can answer, so every headless run has an OPFS workspace — §1.14's third outcome,
1358 /// and a refusal. `dev/verify_wsident.mjs` therefore tests the refusal itself, with two real
1359 /// directory handles standing in for the two folders; `dev/verify_ptyedge.mjs` and
1360 /// `dev/verify_handreal.mjs` assert the refusal once against the real hand and then stand in
1361 /// for `status` for the rest, as `dev/verify_scope.mjs` does.
1362 function settled() {
1363 return proveFolder().then(function (p) {
1364 var out = JSON.parse(mine());
1365 out.workspace = p.ok ? 'ok' : 'mismatch';
1366 if (!p.ok) {
1367 out.paired = false;
1368 out.reason = p.why;
1369 out.workspace_reason = p.why;
1370 }
1371 return JSON.stringify(out);
1372 }, function () {
1373 // The check could not answer, which is not the same as answering yes. A folder that
1374 // cannot be compared is refused for the same reason a missing one is: the comparison
1375 // is what stands between a command and the wrong files.
1376 var out = JSON.parse(mine());
1377 out.paired = false;
1378 out.workspace = 'unchecked';
1379 out.workspace_reason = 'Daimond could not check whether the folder you have open is '
1380 + 'the folder the machine hand was granted, so it will not run a command that '
1381 + 'might reach different files from the ones it has been reading. Reopen the '
1382 + 'folder for this workspace and try again.';
1383 out.reason = out.workspace_reason;
1384 return JSON.stringify(out);
1385 });
1386 }
1387
1388 /// What we know about the hand now, as the tool reads it.
1389 function mine() {
1390 return JSON.stringify({
1391 paired: true,
1392 transport: state.transport,
1393 machine: state.machine,
1394 version: state.version,
1395 os: state.os,
1396 root: state.root,
1397 caps: state.caps,
1398 });
1399 }
1400
1401 /// Forget the hand. Called when the user revokes the grant, so the next
1402 /// command refuses rather than reaching a hand they have withdrawn.
1403 function forget() {
1404 // The proof goes with it: a grant that has been withdrawn cannot vouch for a folder,
1405 // and the next hand may be a different one in a different place.
1406 wsProof = null;
1407 state.transport = 'none';
1408 state.machine = '';
1409 state.version = '';
1410 state.os = '';
1411 state.root = '';
1412 state.caps = [];
1413 }
1414
1415 /// Let go of the link, without forgetting that a hand was ever there.
1416 ///
1417 /// SAYS GOODBYE FIRST, and that word is the whole of the difference between
1418 /// this and a page that merely went away. `ext/hand.js` parks the relay and
1419 /// its host for a grace when a page port dies, so the same tab reloaded
1420 /// re-attaches to the run it left going; `bye` is the wire's word for "I am
1421 /// finished with this host", and it is what makes the extension end the pair
1422 /// at once instead (ext/hand.js, `park`).
1423 ///
1424 /// It was not sent, from the day `close` was written until 2026-08-25, so
1425 /// `closing` could not be set from the shipped page at all and that branch of
1426 /// `park` was unreachable. A page that let go of the hand and asked for it
1427 /// again silently ADOPTED THE SAME HOST PROCESS, still holding the folder it
1428 /// had been granted -- measured in `dev/verify_handrun.mjs`, where thirteen
1429 /// consecutive cases each registered a fresh mock host and every one of them
1430 /// was answered by the first.
1431 function letGo(sayBye) {
1432 if (!link) return;
1433 if (sayBye) {
1434 try { link.port.postMessage({ t: 'bye' }); } catch (e) { /* already gone */ }
1435 }
1436 drop(link, 'The page let go of the machine hand.');
1437 }
1438 function close() { letGo(true); }
1439
1440 detect();
1441
1442 // A TAB THAT GOES AWAY IS NOT A PAGE THAT SAID GOODBYE, and this one must not
1443 // say it. `pagehide` fires on a reload as well as on a close, and a reload
1444 // inside the grace is meant to find its command still running on the same
1445 // hand -- the owner's decision of 2026-08-25, asserted in
1446 // `dev/verify_handreload.mjs`. Saying `bye` here would end the host at once
1447 // and take that grace away from every F5. The extension ends the pair on its
1448 // own when the grace runs out and nothing has come back for it.
1449 window.addEventListener('pagehide', function () { letGo(false); });
1450
1451 window.DaimondHand = {
1452 init: function (d) {
1453 deps = d || {};
1454 if (deps.extId) state.extId = deps.extId;
1455 },
1456 setExtId: function (id) { state.extId = id || ''; },
1457 adopt: adopt,
1458 forget: forget,
1459 close: close,
1460 status: status,
1461 run: run,
1462 file: file,
1463 runs: runs,
1464 dirs: dirs,
1465 grant: grant,
1466 held: held,
1467 signal: signal,
1468 send: send,
1469 subscribe: subscribe,
1470 hasHand: function () { return state.transport !== 'none'; },
1471 /// Whether this page's folder is the folder the hand was granted, and the sentence to
1472 /// show when it is not. Resolves `{ ok, why }`; see `proveFolder`.
1473 workspaceProof: proveFolder,
1474 /// Test only. The waits are tens of seconds by design, and a test cannot
1475 /// spend a minute proving a hand is unresponsive. Same-origin callers
1476 /// only, and the worst one can do with it is make its own commands give
1477 /// up sooner.
1478 _setWaitsForTest: function (o) {
1479 if (typeof o === 'number') o = { grace: o };
1480 o = o || {};
1481 if (o.grace > 0) REPLY_GRACE = o.grace;
1482 if (o.slack > 0) REPLY_SLACK = o.slack;
1483 if (o.hello > 0) HELLO_WAIT = o.hello;
1484 // The two the handshake's own deadline now leans on. Without these a test
1485 // that shortened `hello` still waited out the extension's question and the
1486 // two minutes behind it, which is the whole of what this door is for.
1487 if (o.status > 0) STATUS_WAIT = o.status;
1488 if (o.grant > 0) GRANT_WAIT = o.grant;
1489 if (o.keep > 0) { KEEP_HEAD = o.keep; KEEP_TAIL = o.keep; }
1490 return { grace: REPLY_GRACE, slack: REPLY_SLACK, hello: HELLO_WAIT,
1491 status: STATUS_WAIT, grant: GRANT_WAIT, keep: KEEP_HEAD };
1492 },
1493 };
1494})();