oxedyne/daimond/src/wasm/hand.rs
11.7 KiB, 1 run
created by r2519314175:983, 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 | //! The machine hand's edge — thin bindings to the JS relay `window.DaimondHand`. |
| 2 | //! |
| 3 | //! A web page cannot create a process. There is no flag and no future API, so |
| 4 | //! the capability lives in a program outside the page: a native messaging host |
| 5 | //! that Chrome launches and connects to the Daimond Hands extension, and to |
| 6 | //! nothing else. The browser is the doorman. A loopback daemon was rejected |
| 7 | //! because its port is reachable by any page the user visits and its whole |
| 8 | //! defence would be one pasted secret. |
| 9 | //! |
| 10 | //! This file is the narrow part of that road. `window.DaimondHand` hides |
| 11 | //! which transport is attached -- the extension on this machine, or the same |
| 12 | //! hand binary over a WebSocket on a machine you own -- so the tool above does |
| 13 | //! not change when the second one appears. |
| 14 | //! |
| 15 | //! A rejection carries a plain-English `Error` the model is meant to read and |
| 16 | //! act on, so its `message` is passed through **verbatim**, exactly as the Web |
| 17 | //! panel edge does. Mangling it would destroy the only instruction the model |
| 18 | //! gets about what to do next. |
| 19 | |
| 20 | use crate::wasm::js_str; |
| 21 | |
| 22 | use oxedyne_fe2o3_core::prelude::*; |
| 23 | |
| 24 | use wasm_bindgen::prelude::wasm_bindgen; |
| 25 | use wasm_bindgen::{JsCast, JsValue}; |
| 26 | use wasm_bindgen_futures::JsFuture; |
| 27 | |
| 28 | |
| 29 | #[wasm_bindgen] |
| 30 | extern "C" { |
| 31 | |
| 32 | /// The relay object the page installs at `window.DaimondHand`. |
| 33 | #[wasm_bindgen(js_name = DaimondHand)] |
| 34 | type Relay; |
| 35 | |
| 36 | /// Whether a hand is paired, which machine it is, and what it can enforce. |
| 37 | #[wasm_bindgen(method)] |
| 38 | fn status(this: &Relay) -> js_sys::Promise; |
| 39 | |
| 40 | /// Run one command, resolving when it has finished. |
| 41 | /// |
| 42 | /// The relay streams the output to the panel as it arrives; what comes |
| 43 | /// back here is the whole run, because a tool result is one blob and the |
| 44 | /// model reads it once. The live stream is for the person watching. |
| 45 | #[wasm_bindgen(method)] |
| 46 | fn run(this: &Relay, spec_json: &str) -> js_sys::Promise; |
| 47 | |
| 48 | /// What the hand is still running, standing process groups included. |
| 49 | #[wasm_bindgen(method)] |
| 50 | fn runs(this: &Relay) -> js_sys::Promise; |
| 51 | |
| 52 | /// The output a page reload left this page holding for one run. |
| 53 | #[wasm_bindgen(method)] |
| 54 | fn held(this: &Relay, id: &str) -> js_sys::Promise; |
| 55 | |
| 56 | /// Signal one run by the identifier it was given, resolving when the |
| 57 | /// message has been handed over. |
| 58 | #[wasm_bindgen(method)] |
| 59 | fn signal(this: &Relay, id: &str, sig: &str) -> js_sys::Promise; |
| 60 | } |
| 61 | |
| 62 | |
| 63 | /// Reach the relay object on `window`, or refuse in the model's language. |
| 64 | /// |
| 65 | /// The refusal is the most-read sentence in this file: a user who has not |
| 66 | /// installed the hand meets it on their first command, so it says what is |
| 67 | /// missing and what to do, not merely that something failed. |
| 68 | fn relay() -> Outcome<Relay> { |
| 69 | let win = res!(web_sys::window() |
| 70 | .ok_or_else(|| err!("The machine hand needs a browser window."; System, Missing))); |
| 71 | let obj = res!(js_sys::Reflect::get(&win, &JsValue::from_str("DaimondHand")) |
| 72 | .map_err(|e| err!("Reading window.DaimondHand failed: {}.", js_str(&e); System, Missing))); |
| 73 | if obj.is_undefined() || obj.is_null() { |
| 74 | return Err(err!( |
| 75 | "There is no machine hand in this page, so there is nothing to run \ |
| 76 | commands on. Daimond runs in the browser and a browser cannot start \ |
| 77 | a program; the hand is a small companion that can. Tell the user it \ |
| 78 | is not installed, and carry on with the file tools, which do not \ |
| 79 | need it."; |
| 80 | System, Missing)); |
| 81 | } |
| 82 | Ok(obj.unchecked_into::<Relay>()) |
| 83 | } |
| 84 | |
| 85 | /// The `message` of a rejected JS `Error`, verbatim, falling back to the |
| 86 | /// value's own rendering when it is not an `Error`. |
| 87 | fn refusal(e: &JsValue) -> String { |
| 88 | match js_sys::Reflect::get(e, &JsValue::from_str("message")) { |
| 89 | Ok(m) => m.as_string().unwrap_or_else(|| js_str(e)), |
| 90 | Err(_) => js_str(e), |
| 91 | } |
| 92 | } |
| 93 | |
| 94 | /// Render a resolved JS value as the JSON string the tool result carries. |
| 95 | fn stringify(v: &JsValue) -> Outcome<String> { |
| 96 | if v.is_undefined() || v.is_null() { |
| 97 | return Ok("{}".to_string()); |
| 98 | } |
| 99 | if let Some(s) = v.as_string() { |
| 100 | return Ok(s); // the relay resolved with JSON already |
| 101 | } |
| 102 | match js_sys::JSON::stringify(v) { |
| 103 | Ok(s) => Ok(String::from(s)), |
| 104 | Err(e) => Err(err!( |
| 105 | "The machine hand returned a result that cannot be read: {}.", refusal(&e); |
| 106 | Invalid, Data)), |
| 107 | } |
| 108 | } |
| 109 | |
| 110 | /// Await a relay promise, passing a refusal through untouched. |
| 111 | async fn settle(promise: js_sys::Promise) -> Outcome<String> { |
| 112 | match JsFuture::from(promise).await { |
| 113 | Ok(v) => stringify(&v), |
| 114 | Err(e) => Err(err!("{}", refusal(&e); IO, Invalid)), |
| 115 | } |
| 116 | } |
| 117 | |
| 118 | /// Whether a machine hand is present. |
| 119 | /// |
| 120 | /// AUTHORED BY A DAIMON, `dev/reflux.mjs --task opfssay`, 2026-08-24, comment included. It is |
| 121 | /// asked by [`crate::tools::two_places_note`] and by [`crate::tools::write_place`] to decide |
| 122 | /// whether there is a second filesystem worth naming; [`status`] answers more and costs a round |
| 123 | /// trip, on a path that has already failed. |
| 124 | /// |
| 125 | /// **It was `relay().is_ok()` until 2026-08-24 and that was the wrong question.** `hand.js` |
| 126 | /// installs `window.DaimondHand` on EVERY page, paired or not -- it is the shim that answers |
| 127 | /// "no hand is installed", so its presence is not evidence of one. So the two-filesystems note |
| 128 | /// was appearing for a user with no hand at all, telling them about a granted folder on their |
| 129 | /// computer that did not exist. `hasHand()` is the relay's own answer, `transport !== 'none'`, |
| 130 | /// and is what "present" always meant. |
| 131 | /// |
| 132 | /// A relay that cannot answer is taken as no hand. Silence loses a hint; the other direction |
| 133 | /// invents a second filesystem, which is the fault being fixed. |
| 134 | pub fn present() -> bool { |
| 135 | let r = match relay() { |
| 136 | Ok(r) => r, |
| 137 | Err(_) => return false, |
| 138 | }; |
| 139 | let obj: &JsValue = r.as_ref(); |
| 140 | let f = match js_sys::Reflect::get(obj, &JsValue::from_str("hasHand")) { |
| 141 | Ok(v) => v, |
| 142 | Err(_) => return false, |
| 143 | }; |
| 144 | let f: js_sys::Function = match f.dyn_into() { |
| 145 | Ok(f) => f, |
| 146 | Err(_) => return false, |
| 147 | }; |
| 148 | matches!(f.call0(obj), Ok(v) if v.is_truthy()) |
| 149 | } |
| 150 | |
| 151 | /// Whether a hand is paired, which machine it is, and what it can enforce. |
| 152 | pub async fn status() -> Outcome<String> { |
| 153 | let r = res!(relay()); |
| 154 | settle(r.status()).await |
| 155 | } |
| 156 | |
| 157 | /// Run one command and return the whole result as JSON. |
| 158 | /// |
| 159 | /// A rejection from the relay is **not** returned as an error, and that is |
| 160 | /// deliberate. Every one of them is a whole sentence written for the model to |
| 161 | /// act on -- the hand is not installed, the user declined, it stopped |
| 162 | /// part-way -- and an `Err` reaches the daimon wrapped in an fe2o3 chain, |
| 163 | /// carrying ANSI colour and a `src/*.rs:line` frame around the one sentence |
| 164 | /// that matters. A hand that will not run a command has refused it, so it is |
| 165 | /// handed on as a refusal and rendered as one. |
| 166 | /// |
| 167 | /// # Arguments |
| 168 | /// * `spec_json` - The `exec` request, already rendered as the wire's JSON. |
| 169 | pub async fn run(spec_json: &str) -> Outcome<String> { |
| 170 | let r = res!(relay()); |
| 171 | match JsFuture::from(r.run(spec_json)).await { |
| 172 | Ok(v) => stringify(&v), |
| 173 | Err(e) => Ok(fmt!(r#"{{"refused":"{}"}}"#, crate::llm::json_escape(&refusal(&e)))), |
| 174 | } |
| 175 | } |
| 176 | |
| 177 | /// Carry out one file operation and return the answer as JSON. |
| 178 | /// |
| 179 | /// **Reached reflectively, not through a `#[wasm_bindgen(method)]` declaration, and the |
| 180 | /// reason is a hang.** A declared method that is not on the object throws when it is called, |
| 181 | /// and a throw out of a declared-infallible import does not become an `Err` -- the promise is |
| 182 | /// never made, nothing resolves, and the tool call waits for ever. A relay older than this |
| 183 | /// build is exactly that case, and so is every test stub written before the verb existed: |
| 184 | /// `dev/verify_chatfence.mjs`'s stub answers `hasHand`, `status` and `run`, and its first |
| 185 | /// `file_write` to a marked folder hung the whole verifier. |
| 186 | /// |
| 187 | /// So the function is looked up, and its absence is a SENTENCE. Never a fall back to browser |
| 188 | /// storage: a write that lands in the other filesystem while the daimon believes it changed |
| 189 | /// the machine is the failure `write_place` exists to refuse, and doing it here silently would |
| 190 | /// be that failure with a new door on it. |
| 191 | /// |
| 192 | /// A rejection is handed on as a refusal for exactly the reason [`run`] gives: every one of |
| 193 | /// them is a whole sentence written for the model to act on, and an `Err` would wrap it in an |
| 194 | /// fe2o3 chain carrying colour and a `src/*.rs:line` frame around the one sentence that |
| 195 | /// matters. |
| 196 | /// |
| 197 | /// # Arguments |
| 198 | /// * `spec_json` - The `file` request, already rendered as the wire's JSON. |
| 199 | pub async fn file(spec_json: &str) -> Outcome<String> { |
| 200 | let r = res!(relay()); |
| 201 | let obj: &JsValue = r.as_ref(); |
| 202 | let f = match js_sys::Reflect::get(obj, &JsValue::from_str("file")) { |
| 203 | Ok(v) => v, |
| 204 | Err(_) => return Ok(no_file_door()), |
| 205 | }; |
| 206 | let f: js_sys::Function = match f.dyn_into() { |
| 207 | Ok(f) => f, |
| 208 | Err(_) => return Ok(no_file_door()), |
| 209 | }; |
| 210 | let p = match f.call1(obj, &JsValue::from_str(spec_json)) { |
| 211 | Ok(p) => p, |
| 212 | Err(e) => return Ok(fmt!(r#"{{"refused":"{}"}}"#, |
| 213 | crate::llm::json_escape(&refusal(&e)))), |
| 214 | }; |
| 215 | let p: js_sys::Promise = match p.dyn_into() { |
| 216 | Ok(p) => p, |
| 217 | Err(_) => return Ok(no_file_door()), |
| 218 | }; |
| 219 | match JsFuture::from(p).await { |
| 220 | Ok(v) => stringify(&v), |
| 221 | Err(e) => Ok(fmt!(r#"{{"refused":"{}"}}"#, crate::llm::json_escape(&refusal(&e)))), |
| 222 | } |
| 223 | } |
| 224 | |
| 225 | /// What to say when the relay attached to this page cannot carry a file operation. |
| 226 | /// |
| 227 | /// It names the two things the reader can do about it, because "the hand is old" is not an |
| 228 | /// instruction: update it, or reach the file with `run`, which every hand has always had. |
| 229 | fn no_file_door() -> String { |
| 230 | fmt!(r#"{{"refused":"{}"}}"#, crate::llm::json_escape( |
| 231 | "The machine hand attached to this page cannot change files directly -- it is older \ |
| 232 | than this version of Daimond. Nothing was read or changed. Tell the user to update \ |
| 233 | the hand, and meanwhile reach the file with run, which every hand can do.")) |
| 234 | } |
| 235 | |
| 236 | /// What this hand is still running, as the relay's JSON. |
| 237 | /// |
| 238 | /// Two things a caller must not read into it. The listing is a MEASUREMENT |
| 239 | /// taken a moment ago, so a run in it may have ended since; and a run absent |
| 240 | /// from it is one the hand can no longer reach, which is not the same claim as |
| 241 | /// one that has stopped. |
| 242 | pub async fn runs() -> Outcome<String> { |
| 243 | let r = res!(relay()); |
| 244 | settle(r.runs()).await |
| 245 | } |
| 246 | |
| 247 | /// The output held for one run across a page reload, as the relay's JSON. |
| 248 | /// |
| 249 | /// **Handed over once.** The relay lets go of what it answers with, because the whole of it has |
| 250 | /// gone to the reader and a second copy of a build's output in a tab is one nobody will look at |
| 251 | /// again. So a caller that discards this answer has discarded the output. |
| 252 | /// |
| 253 | /// # Arguments |
| 254 | /// * `id` - The run's identifier, as the listing carries it. |
| 255 | pub async fn held(id: &str) -> Outcome<String> { |
| 256 | let r = res!(relay()); |
| 257 | settle(r.held(id)).await |
| 258 | } |
| 259 | |
| 260 | /// Signal one run this hand started, by the identifier the run was given. |
| 261 | /// |
| 262 | /// **Resolving promises nothing about the process.** There is deliberately no |
| 263 | /// "stopped" answer on the wire: a signal that could not be delivered comes back |
| 264 | /// as an error, and a signal that could is confirmed by asking [`runs`] again. |
| 265 | /// Reporting success on a kill that failed is the defect that arrangement exists |
| 266 | /// to close, so nothing here may be read as one. |
| 267 | /// |
| 268 | /// # Arguments |
| 269 | /// * `id` - The identifier the run was given, never a pid and never a pattern. |
| 270 | /// * `sig` - `term`, `kill` or `int`. |
| 271 | pub async fn signal(id: &str, sig: &str) -> Outcome<()> { |
| 272 | let r = res!(relay()); |
| 273 | res!(settle(r.signal(id, sig)).await); |
| 274 | Ok(()) |
| 275 | } |