diff --git a/.claude/nocache-server.py b/.claude/nocache-server.py
index 367cc2ae..2b42dff8 100644
--- a/.claude/nocache-server.py
+++ b/.claude/nocache-server.py
@@ -11,7 +11,9 @@
"""
import http.server
import os
+import socket
import sys
+import threading
# Serve from the repo root (two levels up from .claude/).
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
@@ -31,8 +33,20 @@ def log_message(self, fmt, *args):
def main():
port = int(sys.argv[1]) if len(sys.argv) > 1 else int(os.environ.get("PORT", "8091"))
os.chdir(ROOT)
- httpd = http.server.HTTPServer(("127.0.0.1", port), NoCacheHandler)
- sys.stderr.write("Arena Studio dev server: http://127.0.0.1:%d/ (root: %s)\n" % (port, ROOT))
+ # Threaded: with the single-threaded HTTPServer, Chrome's idle pre-connected sockets
+ # block every other request, so the Studio's module graph (≈ 60 imports) stalls and the
+ # whole
-
-
-
-
+
+
+
+
@@ -3464,7 +3471,8 @@
Import error
-
+
+
@@ -3692,7 +3700,7 @@ Import error
function renderRunEvent(ev) {
if (!$('runlogBox') || !ev) return;
const div = document.createElement('div');
- if (ev.phase === 'error') div.className = 'l-err';
+ if (ev.phase === 'error' || ev.phase === 'bridge-lost') div.className = 'l-err';
else if (ev.phase === 'skip' || ev.phase === 'warn') div.className = 'l-warn';
// Reuse run-log.js's own transcript line if available, else a compact fallback.
div.textContent =
@@ -3701,6 +3709,13 @@ Import error
(ev.step && ev.step.conditionName ? ' · ' + ev.step.conditionName : '') +
(ev.op ? ' ' + ev.op : '') +
(ev.plugin_name ? ' ' + ev.plugin_name + (ev.command_name ? '.' + ev.command_name : '') : '') +
+ // Connector replies (BIAS etc.): outcome, round trip, and the artifact it produced.
+ (ev.op === 'connectorCommand' && ev.value
+ ? ' → ' + (ev.value.outcome || '?') +
+ (ev.value.rtt_ms != null ? ' · ' + Math.round(ev.value.rtt_ms) + ' ms' : '') +
+ (ev.value.value != null && typeof ev.value.value !== 'object' ? ' · ' + ev.value.value : '') +
+ (ev.value.artifact && ev.value.artifact.path ? ' · ' + ev.value.artifact.kind + ' ' + ev.value.artifact.path : '')
+ : '') +
(ev.reason ? ' (' + ev.reason + ')' : '');
enqueueLogNode(div);
}
@@ -3740,7 +3755,7 @@ Import error
// broker has already aborted the runner (bench 2026-09-13, forced watchdog reset: the
// terminal event arrived with fault null). The link being down at the terminal event
// of an abort nobody requested is the same evidence — use either.
- if (sm && !sm.fault && sm.aborted && !Studio._userStopRequested && (Studio._linkDropped || !(Studio.session && Studio.session.connected))) {
+ if (sm && !sm.fault && !sm.bridgeLost && sm.aborted && !Studio._userStopRequested && (Studio._linkDropped || !(Studio.session && Studio.session.connected))) {
sm.fault = 'link_dropped';
sm.faultDetail = { kind: 'link_dropped', note: 'controller re-enumerated mid-run (watchdog self-reset?)' };
}
@@ -3890,6 +3905,16 @@ Import error
// 2026-09-13: they used to land after the export and were lost from committed logs).
const finalizeThenCommit = async () => {
try { if (Studio.finalizeTrialQuality) await Studio.finalizeTrialQuality(log.meta && log.meta.run_id); } catch (_) { /* best effort */ }
+ // The run is over: close the bridge's run file (and, on the ROS bridge node, the
+ // MCAP bag) NOW — after the trial-quality finalizer and the post-mortem (this runs
+ // after both), before any upload, and whether or not the run is committed. A run
+ // with no course token, a Test run or an abort used to leave the file open until
+ // the next run. Only THIS run's file: a newer run may have opened its own since.
+ // "⇪ Push log" still works — the bridge exports its most recent file.
+ try {
+ const b = Studio.session && Studio.session.bridge;
+ if (b && b.connected && b.logging && Studio.activeRunLog === log) b.setLogging(false);
+ } catch (_) { /* best effort */ }
await maybeCommitRunLog(log, summary);
};
if (summary && summary.outcome === 'CONTROLLER_FAULT' && Studio.handleControllerFault) {
@@ -3934,6 +3959,12 @@ Import error
rawLine('run log NOT auto-committed (' + outcome + ') — click "⇪ Push log" to commit it anyway', 'warn');
return;
}
+ if (outcome === 'BRIDGE_LOST') {
+ // The bridge that wrote this run's file is gone; it cannot be exported from here.
+ rawLine('run log NOT auto-committed (BRIDGE_LOST) — the bridge’s file ' +
+ (log.bridgeFile || 'arena-log-*.jsonl') + ' stays on the bridge PC; ⬇ Save keeps this browser’s record', 'warn');
+ return;
+ }
await commitRunLog(log, {});
}
// SHA-256 hex of a byte buffer (SubtleCrypto; secure-context only — the course
@@ -4007,9 +4038,12 @@ Import error
return;
}
const bridge = Studio.session && Studio.session.bridge;
+ const lostRun = !!(log.summary && log.summary.outcome === 'BRIDGE_LOST');
if (!bridge || !bridge.connected) {
showUploadModal('Run log not committed', { error: true,
- msg: 'Bridge disconnected — the log file remains on the bridge machine. Reconnect the bridge (it re-serves the last run) and push again, or commit it from a local clone.' });
+ msg: lostRun
+ ? 'This run lost its bridge. Its log file (' + (log.bridgeFile || 'arena-log-*.jsonl') + ') stays on the bridge PC — a restarted bridge cannot serve it. Commit it from there; ⬇ Save keeps this browser’s record.'
+ : 'Bridge disconnected — the log file remains on the bridge machine. Reconnect the bridge (it re-serves the last run) and push again, or commit it from a local clone.' });
return;
}
let exported;
@@ -4022,6 +4056,19 @@ Import error
showUploadModal('Run-log export failed', { error: true, msg: m + '\n\nThe log stays on the bridge machine — nothing lost.' });
return;
}
+ // log_export serves the bridge's current-or-most-recent file. After a bridge loss
+ // and restart that is a DIFFERENT file — never commit it under this run's name.
+ const runFile = log.bridgeFile || null;
+ if ((runFile && exported.name && exported.name !== runFile) || (lostRun && !runFile)) {
+ const m = runFile
+ ? 'The bridge served ' + exported.name + ', not this run’s log ' + runFile +
+ ' (the bridge was restarted or logged something else since).'
+ : 'This run lost its bridge, and the bridge did not name the run’s log file, so the file it serves now cannot be matched to this run.';
+ rawLine('run log NOT committed — ' + m, 'err');
+ showUploadModal('Run log not committed', { error: true,
+ msg: m + '\n\nThis run’s file is on the bridge PC (' + (runFile || 'arena-log-*.jsonl') + ') — commit it from there. ⬇ Save keeps this browser’s record.' });
+ return;
+ }
const stamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
const proto = GH.slug(String(log.meta.protocol_filename || 'protocol').replace(/\.ya?ml$/i, ''));
const who = GH.slug(log.meta.experimenter || 'unknown');
@@ -4437,6 +4484,21 @@ Import error
setTimeout(() => b.classList.remove('show'), 8000);
}
Studio.showBanner = showBanner;
+ // Persistent alert for safety events (#runAlert): no auto-hide, not replaced by
+ // routine banners. Cleared by its ✕ or when the next run starts.
+ function showRunAlert(msg) {
+ const a = $('runAlert');
+ if (!a) { showBanner(msg); return; }
+ $('runAlertTxt').textContent = msg;
+ a.classList.add('show');
+ }
+ function hideRunAlert() {
+ const a = $('runAlert');
+ if (a) a.classList.remove('show');
+ }
+ Studio.showRunAlert = showRunAlert;
+ Studio.hideRunAlert = hideRunAlert;
+ if ($('runAlertClose')) $('runAlertClose').addEventListener('click', hideRunAlert);
// ---- Safe mode (default student mode) -------------------------------------
// Safe mode is the DEFAULT: a plain load locks Edit, Console, the session-rig
@@ -5828,6 +5890,29 @@ Import error
(d.lastError ? ' (' + d.lastError + ')' : ''), 'err');
showBanner('⚠ Controller stopped answering — run aborted. Post-mortem probes follow (see the run log).');
});
+ // FicTrac bridge lost (LAB-303 S1): the socket dropped without a Disconnect.
+ // The broker has already stopped the run when it needed the bridge (closed loop,
+ // a FicTrac step, or a recorded run's log) — say so where nobody can miss it.
+ Studio.session.on('bridgelost', (e) => {
+ const info = (e && e.info) || {};
+ const action = e && e.action;
+ const why = info.apply ? 'the closed loop was running' : 'the bridge connection dropped';
+ const restart = ' Restart the bridge program on the rig PC before the next run.';
+ let msg = null;
+ if (action === 'aborted') {
+ msg = '⚠ FicTrac bridge lost mid-run (' + why + ') — the run was STOPPED (outcome BRIDGE_LOST). ' +
+ 'The display was stopped and the closed loop is off. Devices the bridge drives (camera, microscope) ' +
+ 'were NOT told to stop — check them. The bridge’s log of this run ends at the loss; ' +
+ '⬇ Save keeps this browser’s record.' + restart;
+ } else if (action === 'continued') {
+ msg = '⚠ FicTrac bridge lost — this test run continues, but nothing after this point is in the bridge log.' + restart;
+ } else if (info.apply) {
+ msg = '⚠ FicTrac bridge lost — the closed loop stopped (the arena no longer follows the ball).' + restart;
+ }
+ rawLine('⚠ bridge lost' + (info.code != null ? ' (code ' + info.code + ')' : '') +
+ (action ? ' — run ' + (action === 'aborted' ? 'stopped' : action) : ''), 'err');
+ if (msg) showRunAlert(msg);
+ });
Studio.session.on('disconnect', (info) => {
if (info && info.involuntary) Studio._linkDropped = true; // consumed by runSteps (fault lifecycle)
if (info && info.involuntary && Studio.activeRunLog && !Studio.activeRunLog.summary) {
@@ -8667,8 +8752,11 @@ Import error
const PP = window.PatPreview;
const BUILTIN_BASE = './patterns/g6_2x10/';
Studio.patternSet = null;
-const webPreviewBySdName = new Map(); // filename → () => Promise
-const repoSharedPreviewKeys = new Set(); // sources currently wired from course-repo patterns/
+// filename → () => Promise, LAYERED so precedence never depends on which
+// listing arrives first: builtin < shared (repo patterns/) < colocated (_patterns/)
+// < uploaded (js/studio-preview-sources.js). Reads see the effective source per filename.
+const webPreviewBySdName = window.StudioPreviewSources.create();
+let sharedPreviewGen = 0; // the newest shared-library listing wins; an older reply is dropped
// In-memory sources first (logical-name tolerant via webBytesForName). The
// fallback for a pattern already on the card from a prior session is a live
// 0x84 fetch of the WHOLE file (the controller has no ranged read), gated by
@@ -9022,7 +9110,7 @@ Import error
const buf = u8.byteOffset === 0 && u8.byteLength === u8.buffer.byteLength
? u8.buffer
: u8.slice().buffer;
- webPreviewBySdName.set(f.name, () => Promise.resolve(buf));
+ webPreviewBySdName.set('uploaded', f.name, () => Promise.resolve(buf));
});
patPreviewCache = {}; // drop cached renders so the new sources take effect
const sel = $('cPatName');
@@ -9037,7 +9125,7 @@ Import error
m.source = 'manifest';
for (const n of Object.keys(m.patterns)) {
const sd = m.patterns[n].sd_name;
- webPreviewBySdName.set(sd, async () => {
+ webPreviewBySdName.set('builtin', sd, async () => {
const r = await fetch(BUILTIN_BASE + 'patterns/' + sd, { cache: 'no-store' });
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.arrayBuffer();
@@ -9766,6 +9854,22 @@ Import error
.filter((p) => p && ((p.matlab && p.matlab.class === 'FicTracPlugin') || p.name === 'fictrac'))
.map((p) => p.name);
}
+// Device plugins the BRIDGE drives for the runner (LAB-303 connectors): plugin instance
+// name → connector id. 'bias' (BiasPlugin → the bridge's BIAS node) and 'scanimage'
+// (ScanImagePlugin → the bridge's ScanImage node, the microscope's JSON server); the
+// runner sends each command as connector_command and waits for the reply, so a camera or
+// microscope plugin in a protocol acts for real instead of being skipped.
+function connectorPluginNames(exp) {
+ const m = new Map();
+ if (!exp || !Array.isArray(exp.plugins)) return m;
+ for (const p of exp.plugins) {
+ if (!p) continue;
+ const cls = p.matlab && p.matlab.class;
+ if (cls === 'BiasPlugin' || p.name === 'camera') m.set(p.name, 'bias');
+ else if (cls === 'ScanImagePlugin' || p.name === 'scanimage') m.set(p.name, 'scanimage');
+ }
+ return m;
+}
// ---- load / render a protocol ----
// The embedded designer (Studio.editor) is the single source of truth for the
@@ -10246,7 +10350,7 @@ Import error
// Open loop: the controller steps the pattern itself (no 0x70s) — exposure is
// in the telemetry FRAME rows, judged by telemetry-report.py, not here.
const exercised = !closedLoop || applied >= Math.min(1000, 5 * durS); // ≥ 5 Hz average, or 1000 total
- let outcome = !res ? 'not-started' : res.fault ? 'fault' : res.aborted ? 'aborted' : exercised ? 'completed' : 'unexercised';
+ let outcome = !res ? 'not-started' : res.fault ? 'fault' : res.bridgeLost ? 'bridge-lost' : res.aborted ? 'aborted' : exercised ? 'completed' : 'unexercised';
logSoak({
phase: 'iteration-end', iteration: s.iteration,
outcome,
@@ -10266,7 +10370,8 @@ Import error
if (!pmo || (pmo.outcome !== 'recovered' && pmo.outcome !== 'self-reset')) { reason = pmo ? 'fault-' + pmo.outcome : 'fault'; break; }
s.resets++;
Studio.rawLog('soak: controller recovered (reset ' + s.resets + '/' + s.maxResets + ') — continuing', 'warn');
- } else if (res.aborted) { reason = 'operator-stop'; break; }
+ } else if (res.bridgeLost) { reason = 'bridge-lost'; break; }
+ else if (res.aborted) { reason = 'operator-stop'; break; }
if (s.stop) { reason = s.stopReason || 'operator'; break; }
// Gap + input freshness re-check (a dead simulator is a harness
// problem, not a controller fault — pause, don't count it).
@@ -10670,6 +10775,20 @@ Import error
const ackLevel = bridge.connected && bridge.waitForLogLevelAck ? await bridge.waitForLogLevelAck(800) : null;
const logLevel = ackLevel || (wantLevel === 'behavior_v2' ? 'behavior_v1' : wantLevel);
Studio._runLogFormat = bridge.connected ? logLevel : null;
+ // The file the bridge opened for THIS run (log_control_ack). A later push checks
+ // that log_export serves it — a bridge restarted after a loss serves another file.
+ if (Studio.activeRunLog) {
+ const runLog = Studio.activeRunLog;
+ runLog.bridgeFile = (bridge.connected && bridge.logFile) || null;
+ // A slow ack (> 800 ms on a loaded rig PC) names the file after this point: take
+ // it then, for THIS run only, so the push check is not silently skipped.
+ if (!runLog.bridgeFile && bridge.connected && typeof bridge.on === 'function') {
+ const off = bridge.on('loglevel', (info) => {
+ if (Studio.activeRunLog !== runLog) { off(); return; }
+ if (info && info.enabled && info.file) { runLog.bridgeFile = info.file; off(); }
+ });
+ }
+ }
const levelNote = !bridge.connected ? ''
: ackLevel === wantLevel ? ' (' + ackLevel + ')'
: ackLevel ? ' ⚠ bridge too old for ' + wantLevel + ' — logging ' + ackLevel + ' (restart `pixi run bridge` from the current checkout)'
@@ -10726,6 +10845,7 @@ Import error
let summary = null;
Studio._userStopRequested = false;
Studio._linkDropped = false;
+ if (Studio.hideRunAlert) Studio.hideRunAlert(); // a new run starts with no stale bridge-lost alert
if (Studio.trialQuality) Studio.trialQuality.reset(); // per-trial stimulus-quality verdicts start fresh
Studio._tqFinal = null; // finalizeTrialQuality() runs once per run (first caller wins, others await it)
// Bind the verdict to THIS run: a late terminal event from a previous (aborted) run must not finalize
@@ -10745,6 +10865,11 @@ Import error
? Studio.resolveRuntimeCondition
: undefined,
fictracPluginNames: new Set(ftNames),
+ connectorPluginNames: connectorPluginNames(exp),
+ // A recorded run's record IS the bridge log (the run is gated on the bridge and
+ // the bridge is logging it), so losing the bridge stops it — not only when a
+ // FicTrac step needs it. With bridge logging off the record is in the browser.
+ bridgeRequired: !!isExperiment && !!(bridge && bridge.connected && bridge.logging),
degPerFrame: Studio.rigDegPerFrame(),
defaultCoupling: Studio._legacyDefaultCoupling == null ? 1 : Studio._legacyDefaultCoupling
});
@@ -11840,23 +11965,20 @@ Import error
// as soon as its filename matches the repo library. Bytes stay lazy: this
// request lists names only; the selected .pat is downloaded on first preview.
// Public repos work signed out, while a token transparently enables private
-// repos. Protocol-colocated sources are registered afterwards and may
-// intentionally override a shared file with the same name.
+// repos. Protocol-colocated sources override a shared file with the same name
+// whenever either listing arrives (the 'colocated' layer outranks 'shared').
async function registerRepoSharedPatternPreviews(repo, token) {
if (!repo) return;
+ const gen = ++sharedPreviewGen;
try {
const res = await GH.run(fetch, GH.reqGetContents(repo.owner, repo.name, 'patterns', null, token));
+ if (gen !== sharedPreviewGen) return; // a newer listing (repo change / sign-in) was requested
if (!res.ok || !Array.isArray(res.data)) return;
- for (const key of repoSharedPreviewKeys) webPreviewBySdName.delete(key);
- repoSharedPreviewKeys.clear();
- let n = 0;
- res.data
+ const entries = res.data
.filter((f) => f && f.type === 'file' && /\.pat$/i.test(f.name))
- .forEach((f) => {
- n++;
- repoSharedPreviewKeys.add(f.name);
- webPreviewBySdName.set(f.name, () => repoFileBytes(repo, f.path));
- });
+ .map((f) => [f.name, () => repoFileBytes(repo, f.path)]);
+ webPreviewBySdName.replaceLayer('shared', entries);
+ const n = entries.length;
if (n) {
patPreviewCache = {};
const sel = $('cPatName');
@@ -11890,7 +12012,7 @@ Import error
.forEach((f) => {
n++;
names.add(sdLogicalName(f.name));
- webPreviewBySdName.set(f.name, () => repoFileBytes(repo, f.path));
+ webPreviewBySdName.set('colocated', f.name, () => repoFileBytes(repo, f.path));
});
if (n) {
// The colocated _patterns/ set IS this protocol's web pattern set
@@ -12150,13 +12272,27 @@ Import error
// bogus/stale params, stamping the {studio, mode} own-refresh marker, and
// never clobbering a mode the user clicked during the awaits below.
Studio._urlSuppress = true;
+ // S2 (ww18 2026-10-15, LAB-303): a tab opened at ?p=… showed no protocol until it was
+ // refreshed. The ?p= load used to wait behind the course repo's shared-pattern listing
+ // (a GitHub API call with no timeout). Rules now: (1) nothing that leaves this origin is
+ // awaited before the protocol load — the GitHub listings below are fire-and-forget, which
+ // is safe because preview sources are LAYERED (js/studio-preview-sources.js); (2) every
+ // same-origin pre-step is guarded — a failure in rig/vocab population logs a warning
+ // instead of silently skipping the protocol load.
+ const guarded = async (name, fn) => {
+ try { await fn(); } catch (e) { Studio.rawLog('init: ' + name + ' failed — ' + (e && e.message ? e.message : e), 'warn'); }
+ };
try {
- await populateMetaDatalists();
- await populateSessionRig();
+ await guarded('metadata vocabularies', populateMetaDatalists);
+ await guarded('session rig', populateSessionRig);
wirePatPicker();
loadBuiltinPatternSet();
- await refreshRepoSharedPatternPreviews(); // shared patterns/ previews do not depend on a protocol
- rewireRepoPatternPreviews(); // restore last-opened course protocol's colocated thumbs
+ // Best-effort GitHub listings — NEVER awaited here: a stalled api.github.com request
+ // used to hold the `?p=` load below until the page was refreshed (LAB-303 S2, ww18
+ // 2026-10-15). Order no longer matters: preview sources are layered, so a late shared
+ // listing cannot override the colocated sources rewired here.
+ refreshRepoSharedPatternPreviews().catch(() => {}); // shared patterns/ previews
+ rewireRepoPatternPreviews().catch(() => {}); // restore last-opened course protocol's colocated thumbs
let allowedKeys = [];
let protoIndex = [];
try {
@@ -12202,6 +12338,10 @@ Import error
} catch (e) {
Studio.showBanner('Protocol fetch failed: ' + e.message);
}
+ } else {
+ // decode() dropped an unknown key with a rawLog warning only; say it where a
+ // student looks (S2 follow-up: a silent empty Run view is what "did not load" means).
+ Studio.showBanner('Protocol "' + state.p + '" is not in this site\'s protocol index (protocols/index.json).');
}
}
// ?rig= (#135): the per-setup bookmark. Applied AFTER the ?p= load so
@@ -14918,7 +15058,14 @@ Import error
title: 'Stop the running pattern (STOP_DISPLAY 0x30) — best-effort queued via the shared session',
onClick: () => { if (window.Studio.session) { window.Studio._userStopRequested = true; window.Studio.session.stop(); } }
}, '■ Stop'));
- const plugins = RunnerLib.listSkippedPlugins(cond);
+ // Plugins the runner drives are not "skipped": the built-in log, FicTrac,
+ // and connector devices (BiasPlugin through the bridge, LAB-303).
+ const driveable = new Set(['log']);
+ for (const p of experiment.plugins || []) {
+ if (p && p.matlab && (p.matlab.class === 'FicTracPlugin' || p.matlab.class === 'BiasPlugin')) driveable.add(p.name);
+ if (p && (p.name === 'fictrac' || p.name === 'camera')) driveable.add(p.name);
+ }
+ const plugins = RunnerLib.listSkippedPlugins(cond, driveable);
wrap.appendChild(el('span', { class: 'arena-test-note' },
plugins.length
? 'unrecorded test · ' + plugins.length + ' plugin cmd' +
@@ -18049,6 +18196,7 @@ Import error
'#btnAllOff': 'Turn every LED off.',
'#btnLogSave': 'Download the run log as files (.json and .txt).',
'#btnLogPush': 'Send the last run’s log to the course repo — use this if the automatic push was skipped or failed.',
+ '#runAlert': 'Something stopped your run or the closed loop (for example, the FicTrac bridge closed). The message says what happened and what to do; the run log keeps it too. ✕ hides it.',
'#btnLogClear': 'Clear the log display (saved logs are not deleted).',
'#btnLogCopy': 'Copy the log text to the clipboard.',
'.seqlist': 'The experiment’s sequence. Each row is a condition — ▶ on a row tests just that one on the arena.',
diff --git a/docs/development/arena-studio-release-notes.md b/docs/development/arena-studio-release-notes.md
index 62e88c46..41e40f90 100644
--- a/docs/development/arena-studio-release-notes.md
+++ b/docs/development/arena-studio-release-notes.md
@@ -4,6 +4,21 @@ The Studio's footer used to carry the full changelog inline; it now shows one li
history lives here. Newest first. (Per-session engineering detail stays in
`arena-studio-handover.md` and the design docs — this file is the user-facing what-changed list.)
+## v0.97 (2026-10-16) · A lost bridge ends the run (BRIDGE_LOST) · Shared `?p=` links load without waiting on GitHub (LAB-303 S1/S2)
+
+
+- **Losing the FicTrac bridge mid-run now ends the run** instead of carrying on in silence. When the bridge's connection drops during a run that depends on it (a closed loop is armed, the protocol drives a camera or another connector, or the bridge is recording the run), the Studio shows a banner, stops the display, and records the run with the outcome **BRIDGE_LOST** (distinct from a controller fault, so no controller post-mortem runs). Restart the bridge, reconnect, and **⇪ Push log** to collect its record of the run. A user Disconnect, or a bridge the run never used, does not end the run.
+- **A `?p=` link loads its protocol first.** Opening a shared protocol link no longer waits for the course repo's pattern listing from GitHub; on a slow or walled network that wait could leave the Run view empty until a refresh. A protocol key that is not in this site's index now says so in a banner.
+- **Run-log pushes can no longer commit the wrong file after a bridge restart.** ⇪ Push log checks that the bridge serves this run's own log file and refuses otherwise, telling you where the file is on the bridge PC.
+- **Protocols that list their variables (`- &name value`) no longer trigger a false "Variable … is defined more than once" warning.** The check read the wrong field on list-style variables; the warning named a JavaScript function instead of a variable.
+- **Camera and microscope steps wait a little longer than their own `timeout_s`** before being called unknown, so a reply that lands right at the deadline counts as the outcome it was.
+
+## v0.96 (2026-10-16) · ScanImage as a protocol plugin (two-photon rigs, ROS bridge node) · Run file and MCAP bag close when the run ends (LAB-303)
+
+
+- **A protocol can drive ScanImage.** Declare a `scanimage` plugin (class `ScanImagePlugin`) and use its commands in conditions: **Set file stem** (the TIFF name for the next grab), **Grab**, **Wait until idle**, **Abort**, and **Set property** for a few allowlisted settings (frames per grab, logging on/off, file path/stem/counter). The commands go through the bridge's ScanImage node to the microscope's own JSON server; every command and reply is in the run log, and the Grab reply names the TIFF stem and counter. Web runner only (MATLAB has no such plugin). First protocol: `g6_2p_color_led_sweep`.
+- **The bridge's run file closes when the run ends,** not when the next run starts. On the ROS bridge node this also closes the MCAP bag, so a run without a course commit (no token, a Test run, an aborted run) no longer leaves its bag open indefinitely. "⇪ Push log" still exports the most recent file. The FicTrac panel's Log checkbox now reads off between runs; turn it on by hand to log Console work outside a run.
+
## v0.95 (2026-10-13) · ISP time estimate ~5 s/panel, not ~20 s (#244) · Pattern Designer + Studio: partial-arena °/px and 3D/2D mapping, unmirrored icons, short SD-safe filenames, upload name checks (LAB-295) · First command after Connect no longer waits ~60 s behind the thumbnail download (LAB-298) · Console → Qwiic peripherals, rig peripherals:, live Console sliders, firmware-sized test display + color banks (LAB-214)
diff --git a/fictrac-bridge/bridge.py b/fictrac-bridge/bridge.py
index fd62d454..058aa756 100644
--- a/fictrac-bridge/bridge.py
+++ b/fictrac-bridge/bridge.py
@@ -173,10 +173,46 @@ class ConnectionClosed(Exception): # type: ignore[no-redef]
BIAS_TYPES = ("none", "constant", "sine", "square")
+# Wall clock for every run-log `ms` / `rx_ms` / t0. On Windows with Python <= 3.12, time.time()
+# is GetSystemTimeAsFileTime, which advances once per system timer tick: 15.625 ms unless some
+# other process (often a browser) has raised the timer rate. Measured on reiserlab-ww18
+# 2026-10-15 with nothing raising it: ~15.6 ms steps, ~2.3 ms behind true time.
+# GetSystemTimePreciseAsFileTime is the same wall clock interpolated with the performance
+# counter (~1 us, ~0.4 us per call), which is what Python 3.13 made time.time(). Everywhere
+# else (macOS, Linux, Windows on 3.13+) now_ms() is time.time() unchanged.
+_FILETIME_UNIX_EPOCH = 116444736000000000 # 100 ns ticks from 1601-01-01 to 1970-01-01
+
+
+def _windows_precise_now_ms():
+ """A ms wall clock on GetSystemTimePreciseAsFileTime, or None where there is none
+ (not Windows, or Windows older than 8)."""
+ if os.name != "nt":
+ return None
+ try:
+ import ctypes
+
+ fn = ctypes.WinDLL("kernel32").GetSystemTimePreciseAsFileTime
+ except (AttributeError, OSError):
+ return None
+ fn.argtypes = [ctypes.POINTER(ctypes.c_ulonglong)]
+ fn.restype = None
+
+ def precise_now_ms() -> int:
+ ft = ctypes.c_ulonglong()
+ fn(ctypes.byref(ft))
+ return (ft.value - _FILETIME_UNIX_EPOCH) // 10_000
+
+ return precise_now_ms
+
+
def now_ms() -> int:
return int(time.time() * 1000)
+if sys.version_info < (3, 13):
+ now_ms = _windows_precise_now_ms() or now_ms # noqa: F811
+
+
def behavior_v1_row(fields: list[float], index: int, rel_ms: int, ft0: float | None) -> dict:
"""Build one behavior_v1 record from a parsed FicTrac line. PURE (no clocks,
no I/O) so it is unit-testable offline — this is where the col-22 ns→ms
diff --git a/js/arena-runner-g6.js b/js/arena-runner-g6.js
index 94fa7f61..cd3a5b73 100644
--- a/js/arena-runner-g6.js
+++ b/js/arena-runner-g6.js
@@ -21,6 +21,9 @@
'use strict';
var ArenaRunnerG6 = (function () {
+ // connectorCommand: added to a step's own timeout_s so the browser outlasts the device
+ // node's deadline (poll period 0.25 s + a status reconcile + two message hops).
+ const CONNECTOR_TIMEOUT_MARGIN_MS = 3000;
// ---- pure helpers (hardware-free, unit-tested) -----------------------
/**
@@ -46,10 +49,18 @@ var ArenaRunnerG6 = (function () {
* warning").
* @returns {Array<{plugin_name:string, command_name:string}>}
*/
- function listSkippedPlugins(condition) {
+ function listSkippedPlugins(condition, driveablePlugins) {
if (!condition || !Array.isArray(condition.commands)) return [];
+ // `driveablePlugins` (optional Set of plugin names): the ones the runner DOES
+ // execute — the built-in `log`, FicTracPlugin instances, and connector-driven
+ // devices (BiasPlugin via the bridge, LAB-303) — so the preflight note counts only
+ // what will really be skipped. Without it every plugin command is listed (legacy).
+ const drive =
+ driveablePlugins && typeof driveablePlugins.has === 'function'
+ ? driveablePlugins
+ : null;
return condition.commands
- .filter((c) => c && c.type === 'plugin')
+ .filter((c) => c && c.type === 'plugin' && !(drive && drive.has(c.plugin_name)))
.map((c) => ({
plugin_name: c.plugin_name || '(unnamed)',
command_name: c.command_name || '(unnamed)'
@@ -714,6 +725,47 @@ var ArenaRunnerG6 = (function () {
return out;
}
+ // JSON-safe copy of a bridge 'lost' info for the run record (the summary and the
+ // bridge-lost event both serialize into the run log).
+ function lostDetail(info) {
+ const out = { kind: 'bridge_lost' };
+ if (!info || typeof info !== 'object') return out;
+ for (const k of ['code', 'reason', 'wasClean', 'apply', 'logFile', 'url', 't']) {
+ const v = info[k];
+ if (v !== undefined && v !== null && v !== '' && typeof v !== 'object') out[k] = v;
+ }
+ return out;
+ }
+
+ /**
+ * Does any condition this sequence runs carry a plugin command that acts through
+ * the bridge (FicTrac today)? Losing the bridge mid-run makes every later such
+ * step run blind, so ArenaRunner.onBridgeLost stops a run for which this is true.
+ * Scans the authored command lists (runtime controls change parameters, never
+ * plugin names), each condition once.
+ * @param {Array} steps
+ * @param {Map} conditionsByName
+ * @param {Set|null} bridgePluginNames plugin instance names routed via the bridge
+ * @returns {boolean}
+ */
+ function sequenceUsesBridge(steps, conditionsByName, bridgePluginNames) {
+ if (!bridgePluginNames || typeof bridgePluginNames.has !== 'function') return false;
+ if (!bridgePluginNames.size) return false;
+ const seen = new Set();
+ for (const step of Array.isArray(steps) ? steps : []) {
+ const name = step && step.conditionName;
+ if (seen.has(name)) continue;
+ seen.add(name);
+ const cond = conditionsByName && conditionsByName.get(name);
+ if (!cond || !Array.isArray(cond.commands)) continue;
+ for (const cmd of cond.commands) {
+ if (cmd && cmd.type === 'plugin' && bridgePluginNames.has(cmd.plugin_name))
+ return true;
+ }
+ }
+ return false;
+ }
+
/**
* Translate ONE v3 command into a wire-neutral instruction descriptor (the
* "command IR"). PURE and wire-free so it is trivially unit-testable; the
@@ -909,6 +961,27 @@ var ArenaRunnerG6 = (function () {
};
}
}
+ // Device connectors (LAB-303): plugins the BRIDGE drives on the runner's behalf
+ // (connector_command → connector_reply). opts.connectorPluginNames maps a plugin
+ // INSTANCE name → connector id ('bias' for BiasPlugin). The command goes through
+ // as written — the bridge / device node validates it and answers ok | error |
+ // unknown; `timeout_s` in params (optional) bounds the wait (LAB-303 semantics).
+ const connectors = opts.connectorPluginNames;
+ const connector =
+ connectors && typeof connectors.get === 'function'
+ ? connectors.get(cmd.plugin_name)
+ : null;
+ if (connector) {
+ const tS = Number(params.timeout_s);
+ return {
+ op: 'connectorCommand',
+ connector,
+ plugin_name: pname,
+ command_name: cname,
+ params: params && typeof params === 'object' ? params : {},
+ timeoutS: Number.isFinite(tS) && tS > 0 ? tS : null
+ };
+ }
// Any other plugin: skipped-with-warning (unchanged — the browser can't
// drive arbitrary MATLAB-side plugins).
return {
@@ -1065,6 +1138,154 @@ var ArenaRunnerG6 = (function () {
await sleep((Number(remainderSec) || 0) * 1000);
}
+ // ---- throttle-proof host-side wait timer ------------------------------
+ //
+ // A page setTimeout is throttled once the tab is hidden or the window is
+ // occluded: Chrome aligns it to 1 s ticks (1 min after 5 min hidden). On the
+ // Bergamo 2P rig (2026-10-16) every host-timed 3 s step ran 4.00 s while the
+ // operator worked in ScanImage over the Studio window. Dedicated Workers are
+ // not throttled, so the wait is armed in a Worker built from an inline blob
+ // URL (no extra file — this module must stay a classic