app
KiCad - the KiCad Bridge
Public Made by Adomby adom
Reference implementation of the KiCad bridge: multi-instance Python server, forward path via kicad-cli, reverse path via in-process plugin. Most complex of the three bundled bridges.
← Commit history
1.0.112: newbie-panel.js 2.22.0 (Hydrogen 1.0.187+ run-prompt job contract), GET /ai-threads/run-prompt/status proxy with the query intact, say_send follows job/status
3 files changed
+149−13
dashboard/kicad_dashboard.py+45−2@@ -147,7 +147,8 @@ _EXACT_ROUTES = ("/health", "/console", "/events", "/api/targets", "/api/action", "/ui/toast", "/ui/click") _SUFFIX_ROUTES = ("/newbie-panel.js", "/favicon.svg", "/favicon.png", "/favicon.ico", "/dock-kicad.svg", "/kicad-logo.png", "/updater/status",- "/api/viewport", "/ai-threads/run-prompt", "/api/say-send")+ "/api/viewport", "/ai-threads/run-prompt", "/ai-threads/run-prompt/status",+ "/api/say-send") _REGEX_ROUTES = (r"/assets/(.+)$", r"^/api/verb_times(?:/([^/]+))?$", r"^/(?:api/status|state)(?:/([^/]+))?(/force)?$")@@ -232,7 +233,7 @@ def say_send(phrase): Still NEW threads only: resume and typing-into-existing stay parked. Every failure returns ok:False with a readable `why`; the page then error-toasts AND copies the phrase, so a user is never stranded."""- import urllib.request+ import urllib.request, urllib.parse base = _hydrogen_control_url() if not base:@@ -271,6 +272,38 @@ def say_send(phrase): f"Not retried, so no duplicate tab. The phrase was copied instead."} d = res.get("data") if isinstance(res.get("data"), dict) else res+ # Hydrogen 1.0.187+ (newbie-panel.js 2.22.0 contract): run-prompt returns in+ # under a second with {ok, job, state:"opening"}; ok means the job STARTED+ # and delivered_to is never filled on this path. Progress is a poll of+ # GET /ai-threads/run-prompt/status?job=<job>: opening, prefilled, submitted,+ # working, needs_you, done; terminal failures submit_failed,+ # submitted_unidentified, submit_unconfirmed, still_working. "working" or+ # later counts as sent. Older builds return delivered_to and fall through.+ job = d.get("job") if isinstance(d, dict) else None+ if job:+ import time as _t+ sent_states = ("working", "needs_you", "done")+ fail_states = ("submit_failed", "submitted_unidentified", "submit_unconfirmed", "still_working")+ last = str(d.get("state") or "opening")+ deadline = _t.monotonic() + 12.0+ while _t.monotonic() < deadline:+ try:+ st = _get("/ai-threads/run-prompt/status?job=" + urllib.parse.quote(str(job)), 4)+ sd = st.get("data") if isinstance(st.get("data"), dict) else st+ last = str(sd.get("state") or last)+ except Exception:+ pass+ if last in sent_states:+ return {"ok": True, "job": job, "state": last,+ "why": "sent: a new AI thread has the phrase and is working on it"}+ if last in fail_states:+ why = ("the prompt is sitting in the new tab and Send was never clicked; press Enter there"+ if last == "submit_failed" else f"the AI panel reported {last}")+ return {"ok": False, "job": job, "state": last, "why": why + ". The phrase was also copied."}+ _t.sleep(1.0)+ return {"ok": True, "job": job, "state": last, "unconfirmed": True,+ "why": f"the new thread is still {last} after 12s; it usually lands within 5s, "+ f"check the new AI tab. The phrase was also copied."} # VERIFY BY DELIVERY, never by the thread's name (issue #59: a correctly # named tab that delivered nothing, and an unnamed tab that delivered - # measured live on AdomLapper 2026-08-25, where a successful send came back@@ -1265,6 +1298,16 @@ class Handler(BaseHTTPRequestHandler): "application/octet-stream") return self._send(200, mime, f.read_bytes(), {"Cache-Control": "no-cache"})+ if path.endswith("/ai-threads/run-prompt/status"):+ # Hydrogen 1.0.187+ (newbie-panel.js 2.22.0): POST run-prompt returns a+ # job id in under a second and the module polls THIS route once a+ # second for opening -> prefilled -> submitted -> working -> done.+ # The query string (?job=<uuid>) must travel intact: a proxy that+ # forwards only the path answers about no job at all, and every poll+ # 404s. Same verbatim pass-through as the other two routes.+ q = urlparse(self.path).query or ""+ return _proxy_control(self, "/ai-threads/run-prompt/status" + (("?" + q) if q else ""),+ timeout=8) if path.endswith("/updater/status"): # LOST IN AN EARLIER REBUILD, which meant the module's version # gate silently no-opped: its gate() catches a failure and
dashboard/ui/newbie-panel.js+103−10@@ -112,6 +112,23 @@ var TWO_COL_MIN = 700, PHRASE_BUDGET = 336; var MIN_API = [1, 0, 142]; + /* Hydrogen 1.0.187+ answers a send with a JOB and reports progress on+ * GET /ai-threads/run-prompt/status?job=<uuid>. Measured on the VM: tab visible ~0.1s,+ * session id ~2.5s, sent by 3-5s, a trivial answer done ~8s. One poll a second is the+ * right cadence for that shape, and it is an in-memory read on the other end. */+ var PHASE = {+ opening: 'Opening a new AI chat…',+ prefilled: 'Prompt ready…',+ submitted: 'Sent, waiting for the AI…',+ working: 'The AI is working…',+ needs_you: 'The AI needs you. Open the tab.',+ done: 'Done.',+ submit_failed: 'The prompt is in the tab. Press Enter there to send it.',+ submitted_unidentified:'Sent, but the AI never checked in.',+ submit_unconfirmed: 'Sent, but delivery was not confirmed.',+ still_working: 'The AI is still working. Open the tab.'+ };+ function css() { if (document.getElementById('anp-css')) return; var s = document.createElement('style');@@ -174,6 +191,9 @@ if (!toastHost) { toastHost = el('div', 'anp-toasts'); document.body.appendChild(toastHost); } var t = el('div', 'anp-toast ' + (kind || 'info'), esc(msg)); toastHost.appendChild(t);+ /* retextable, so a long send can narrate its phases in ONE toast instead of+ * stacking six of them on top of each other. */+ t.setText = function (next) { t.innerHTML = esc(next); return t; }; setTimeout(function () { t.remove(); }, ms || (kind === 'err' ? 7000 : 4000)); return t; }@@ -224,7 +244,7 @@ }) .catch(function () { return { ok: true, version: null, open: true }; }); }- function send(base, phrase, appName, slug) {+ function send(base, phrase, appName, slug, onProgress) { return gate(base).then(function (g) { if (!g.ok) throw new Error('Adom needs updating to send prompts (found ' + g.version + ', needs 1.0.142). Run: adom update'); var ctl = new AbortController(), timer = setTimeout(function () { ctl.abort(); }, 120000);@@ -241,12 +261,55 @@ .then(function (res) { clearTimeout(timer); var j = res.j || {};- /* THREE states, never two. delivered_to is an ARRAY; a thread that opened but did- * not confirm delivery is NOT a failure, and retrying it double-sends. */+ if (!(res.status >= 200 && res.status < 300))+ throw new Error((j._hints && j._hints.note) || j.error || ('AI threads returned ' + res.status));+ /* PRE-1.0.187 SHAPE, kept because older Hydrogen builds still answer this way.+ * delivered_to is an ARRAY; a thread that opened but did not confirm delivery is+ * NOT a failure, and retrying it double-sends. */ var dt = j.delivered_to, arr = Array.isArray(dt) ? dt : (dt ? [dt] : []); if (arr.length || (j.inject && j.inject.delivered_in_conversation)) return { state: 'delivered' };- if (res.status >= 200 && res.status < 300) return { state: 'opened', hint: j._hints && j._hints.note };- throw new Error((j._hints && j._hints.note) || j.error || ('AI threads returned ' + res.status));+ /* 1.0.187+ returns in well under a second with a JOB, not a delivery. ok:true means+ * the job STARTED. delivered_to is never filled on this path any more, so without+ * the poll below every send would report "delivery not confirmed" forever. */+ if (!j.job) return { state: 'opened', hint: j._hints && j._hints.note };+ var started = Date.now(), missing = false;+ return new Promise(function (resolve) {+ (function tick() {+ /* the deadline is checked in BOTH paths. Checking it only after a successful+ * poll leaves a dashboard whose server stops answering polling forever. */+ function again() {+ if (Date.now() - started > 120000)+ return resolve({ state: 'opened', hint: 'no confirmation within 120s' });+ setTimeout(tick, 1000);+ }+ fetch(base + '/ai-threads/run-prompt/status?job=' + encodeURIComponent(j.job))+ .then(function (r) {+ /* THE STATUS ROUTE IS A THIRD PROXY ROUTE. Dashboards proxy /updater/status+ * and POST /ai-threads/run-prompt only. If this one was never added, every+ * poll 404s and the user watches "Opening…" for the full 120s before a+ * hedge, which is worse than the behaviour this replaced. Fail FAST and say+ * exactly which route is missing instead of hanging. */+ if (r.status === 404) { missing = true; return null; }+ return r.json();+ })+ .then(function (st) {+ if (missing) return resolve({ state: 'opened', phase: 'no_status_route',+ hint: 'Your AI chat opened. This app does not proxy GET ' ++ '/ai-threads/run-prompt/status yet, so progress cannot be shown.' });+ var state = (st.job && st.job.state) || '';+ if (typeof onProgress === 'function') { try { onProgress(state, st.job); } catch (e) {} }+ /* working or later IS sent, for the user-facing toast. */+ if (state === 'working' || state === 'done' || state === 'needs_you' ||+ state === 'still_working') return resolve({ state: 'delivered', phase: state });+ if (state === 'submit_failed' || state === 'submitted_unidentified' ||+ state === 'submit_unconfirmed')+ return resolve({ state: 'opened', phase: state,+ hint: (st.job && (st.job.error || st.job.note)) || state });+ again();+ })+ .catch(again);+ })();+ }); }) .catch(function (e) { clearTimeout(timer);@@ -316,7 +379,10 @@ if (sd.disabled) return; sd.disabled = true; var pending = toast('Opening a new AI thread…', 'info', 120000);- send(base, phrase, appName, slug).then(function (r) {+ send(base, phrase, appName, slug, function (state, job) {+ if (PHASE[state]) pending.setText(PHASE[state]);+ if (typeof cfg.onProgress === 'function') { try { cfg.onProgress(state, job); } catch (e) {} }+ }).then(function (r) { pending.remove(); if (r.state === 'delivered') toast('Sent. Your AI thread has the prompt.', 'ok'); else toast('Thread opened, delivery not confirmed. Check your AI threads before sending again.', 'info', 8000);@@ -585,14 +651,41 @@ }) .catch(function (e) { add('gate route wired', '200 with JSON', 'FETCH FAILED: ' + e.message, false); }) .then(function () {- return fetch(base + '/ai-threads/run-prompt', {method: 'OPTIONS'})- .then(function (r) { add('send route wired', 'not 404', String(r.status), r.status !== 404); })- .catch(function (e) { add('send route wired', 'not 404', 'FETCH FAILED: ' + e.message, false); });+ /* PROBE A NONSENSE PATH TOO, AND REQUIRE THE TWO TO DIFFER.+ * "not 404" was too weak an assertion. kicad's do_OPTIONS returned 204 for EVERY path+ * unconditionally, so this row was green for any string at all, including routes that+ * exist in no method. An app could fix its GET catch-all, still have a blanket+ * preflight, and keep a green row that tests nothing. Comparing against a path that+ * cannot exist makes the row self-validating: if they answer identically, the server+ * is answering the SHAPE of the question rather than the question. */+ var nonsense = '/__anp_probe_' + String(Math.floor(1e9 * 0.6180339887)) + '__';+ return Promise.all([+ fetch(base + '/ai-threads/run-prompt', {method: 'OPTIONS'}).then(function (r) { return r.status; },+ function () { return 'ERR'; }),+ fetch(base + nonsense, {method: 'OPTIONS'}).then(function (r) { return r.status; },+ function () { return 'ERR'; })+ ]).then(function (both) {+ var real = both[0], fake = both[1];+ add('send route wired', 'answers, and differently from a path that cannot exist',+ 'route ' + real + ', nonsense ' + fake,+ real !== 'ERR' && real !== 404 && real !== fake);+ });+ })+ .then(function () {+ /* THE STATUS ROUTE, added for Hydrogen 1.0.187+. It is a THIRD proxy route and no+ * dashboard had it before 2.22.0, so it is the row most likely to be red. A GET with+ * a job id that cannot exist must still be ANSWERED (any non-404), because a 404 here+ * means the route was never proxied and progress can never be reported. */+ return fetch(base + '/ai-threads/run-prompt/status?job=__anp_probe__')+ .then(function (r) { return r.status; }, function () { return 'ERR'; })+ .then(function (st) {+ add('progress route wired', 'answers (not 404)', String(st), st !== 'ERR' && st !== 404);+ }); }) .then(function () { return {ok: ok, rows: rows}; }); } root.AdomNewbiePanel = { mount: mount, toast: toast, selfCheck: selfCheck, copyText: copyText,- checkSend: checkSend, version: '2.20.1',+ checkSend: checkSend, version: '2.22.0', TWO_COL_MIN: TWO_COL_MIN, PHRASE_BUDGET: PHRASE_BUDGET }; })(typeof window !== 'undefined' ? window : this);
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "kicad-bridge",- "version": "1.0.111",+ "version": "1.0.112", "type": "app", "description": "Skills for your container so your AI knows how to drive the KiCad bridge. The bridge runtime itself is the release zip; Adom Bridge loads that.", "tags": [