feat(gesture): port session improvements — direct detach, Run/Run Shell taps, self-hosted MediaPipe, cache-bust

Updates the opt-in gesture overlay (still gated by CODEMAN_GESTURE=1):

- Bundle (gesture-codeman.js) rebuilt from Ark0N/codeman-gesture-control:
  - Detach now calls window.app.detachSession(id) directly (the on-tab pop-out
    hook) instead of a separate /session/:id window.open reimplementation.
  - Pinch a session tab → ghost follows your hand → pull out to undock.
  - Pinch the Run (#runBtn → app.run()) or Run Shell (.btn-shell →
    app.runShell()) toolbar button to fire it; drift cancels the tap.
  - Camera shows fullscreen-dimmed by default (⛶ toggles a corner preview).
  - Robust start-error reporting; GPU→CPU MediaPipe delegate fallback.

- Self-hosted MediaPipe (no CDN): serves the wasm runtime + gesture_recognizer
  .task from /gesture/ so a browser content-blocker can't break startup. The
  overlay points wasmBase/modelUrl there. (~27MB of assets; could later be a
  build/postinstall fetch instead of committed blobs.)

- server.ts: cache-bust the injected bundle URL with its mtime (?v=), since
  static is served with a 1-year cache — a redeploy is now never stale.

format:check / lint scope (src/**/*.ts) clean; server.ts typechecks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ark0N
2026-06-08 01:20:23 +02:00
co-authored by Claude Opus 4.8
parent ceca85365c
commit eea84db05e
7 changed files with 224 additions and 45 deletions
+167 -43
View File
@@ -4352,10 +4352,10 @@ var GestureController = class {
}
async loadRecognizer() {
const fileset = await Po.forVisionTasks(this.opts.wasmBase);
this.recognizer = await fc.createFromOptions(fileset, {
const build = (delegate) => fc.createFromOptions(fileset, {
baseOptions: {
modelAssetPath: this.opts.modelUrl,
delegate: "GPU"
delegate
},
runningMode: "VIDEO",
numHands: this.opts.numHands,
@@ -4363,6 +4363,12 @@ var GestureController = class {
minHandPresenceConfidence: this.opts.minDetectionConfidence,
minTrackingConfidence: this.opts.minTrackingConfidence
});
try {
this.recognizer = await build("GPU");
} catch (err) {
console.warn("[gesture] GPU delegate failed; falling back to CPU.", err);
this.recognizer = await build("CPU");
}
}
trackFps(nowMs) {
this.frameTimes.push(nowMs);
@@ -4445,7 +4451,10 @@ var GestureController = class {
// src/codeman/entry.ts
var TAB_SELECTOR = ".session-tab";
var CLICK_SELECTOR = "#runBtn, .btn-shell";
var Z2 = 2147483e3;
var DETACH_PULL_PX = 70;
var TAP_CANCEL_PX = 45;
var handColor = (handedness, pinching) => pinching ? "#4ade80" : handedness === "Right" ? "#a78bfa" : "#38bdf8";
var GestureBridge = class {
constructor() {
@@ -4454,44 +4463,59 @@ var GestureBridge = class {
__publicField(this, "ctx");
__publicField(this, "video");
__publicField(this, "button");
__publicField(this, "camBtn");
__publicField(this, "status");
__publicField(this, "gc");
__publicField(this, "running", false);
/** Per-hand id of the session tab grabbed at pinch-start (detached on drop). */
__publicField(this, "grabbedByHand", /* @__PURE__ */ new Map());
/** Camera view: full-viewport dimmed background, or small corner preview. */
__publicField(this, "camMode", "full");
/** Per-hand in-progress grab (the tab grabbed at pinch-start). */
__publicField(this, "grabs", /* @__PURE__ */ new Map());
/** Per-hand in-progress button pinch (fires on release if it didn't drift). */
__publicField(this, "taps", /* @__PURE__ */ new Map());
injectStyles();
this.surface = el("div", "cg-surface");
this.canvas = el("canvas", "cg-canvas");
this.video = el("video", "cg-preview");
this.video.muted = true;
this.video.playsInline = true;
this.applyCamMode();
const dock = el("div", "cg-dock");
this.button = el("button", "cg-btn");
this.button.textContent = "\u{1F590} Gesture";
this.camBtn = el("button", "cg-btn cg-btn-icon");
this.camBtn.textContent = "\u26F6";
this.camBtn.title = "Toggle camera size (fullscreen / corner)";
this.status = el("span", "cg-status");
this.status.textContent = "off";
dock.append(this.button, this.status);
document.body.append(this.surface, this.canvas, this.video, dock);
dock.append(this.button, this.camBtn, this.status);
document.body.append(this.surface, this.video, this.canvas, dock);
this.ctx = this.canvas.getContext("2d");
this.sizeCanvas();
window.addEventListener("resize", () => this.sizeCanvas());
this.gc = new GestureController({
video: this.video,
surface: this.surface,
numHands: 1
numHands: 1,
// Self-host the MediaPipe runtime + model from Codeman (same-origin) instead
// of the CDN, so an ad/content blocker, offline desk, or strict browser
// can't break startup (the CDN failure surfaced as `failed: {isTrusted}` —
// a resource load-error Event). Served from public/gesture/.
wasmBase: "/gesture/wasm",
modelUrl: "/gesture/gesture_recognizer.task"
});
this.gc.on("grab", (p2) => this.onGrab(p2.hand, p2.x, p2.y));
this.gc.on("drag", (p2) => this.onDrag(p2.hand, p2.x, p2.y));
this.gc.on("drop", (p2) => this.onDrop(p2.hand));
this.gc.on("status", ({ fps, hands }) => this.onStatus(fps, hands));
this.button.addEventListener("click", () => void this.toggle());
this.camBtn.addEventListener("click", () => this.toggleCamMode());
}
async toggle() {
if (this.running) {
this.gc.stop();
this.running = false;
this.grabbedByHand.clear();
this.clearHighlights();
this.cancelAllGrabs();
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
this.button.classList.remove("on");
this.status.textContent = "off";
@@ -4503,42 +4527,118 @@ var GestureBridge = class {
await this.gc.start();
this.running = true;
this.button.classList.add("on");
this.status.textContent = "on \u2014 pinch a tab";
this.status.textContent = "on \u2014 pinch a tab or button";
} catch (err) {
this.status.textContent = `failed: ${err.message}`;
const msg = describeError(err);
this.status.textContent = `failed: ${msg}`;
this.status.title = msg;
console.error("[gesture] start failed", err);
} finally {
this.button.disabled = false;
}
}
/** Tab element at a viewport point (our overlays are click-through). */
tabAt(x2, y2) {
toggleCamMode() {
this.camMode = this.camMode === "full" ? "pip" : "full";
this.applyCamMode();
}
applyCamMode() {
this.video.classList.toggle("cg-full", this.camMode === "full");
this.video.classList.toggle("cg-pip", this.camMode === "pip");
}
/** Top-most element matching `sel` at a viewport point (overlays are
* click-through, so elementFromPoint sees the dashboard beneath). */
hitClosest(x2, y2, sel) {
const hit = document.elementFromPoint(x2, y2);
return hit?.closest(TAB_SELECTOR) ?? null;
return hit?.closest(sel) ?? null;
}
onGrab(hand, x2, y2) {
const tab = this.tabAt(x2, y2);
const tab = this.hitClosest(x2, y2, TAB_SELECTOR);
const id = tab?.dataset.id;
if (id) {
this.grabbedByHand.set(hand, id);
if (tab && id) {
const rect = tab.getBoundingClientRect();
const ghost = tab.cloneNode(true);
ghost.classList.add("cg-ghost");
ghost.removeAttribute("id");
ghost.style.width = `${rect.width}px`;
ghost.style.height = `${rect.height}px`;
document.body.append(ghost);
tab.classList.add("cg-grabbed");
const grab = { id, tab, ghost, ox: x2, oy: y2, armed: false };
this.grabs.set(hand, grab);
this.positionGhost(grab, x2, y2);
return;
}
const btn = this.hitClosest(x2, y2, CLICK_SELECTOR);
if (btn) {
const label = (btn.textContent || btn.getAttribute("title") || "button").trim();
btn.classList.add("cg-tap-armed");
this.taps.set(hand, { el: btn, label, ox: x2, oy: y2 });
this.status.textContent = `release to ${label.toLowerCase()}`;
}
}
onDrag(hand, _x, _y) {
onDrag(hand, x2, y2) {
const grab = this.grabs.get(hand);
if (grab) {
this.positionGhost(grab, x2, y2);
const pulled = Math.hypot(x2 - grab.ox, y2 - grab.oy) >= DETACH_PULL_PX;
if (pulled !== grab.armed) {
grab.armed = pulled;
grab.ghost.classList.toggle("cg-armed", pulled);
this.status.textContent = pulled ? "release to detach" : "on \u2014 pinch a tab";
}
return;
}
const tap = this.taps.get(hand);
if (tap && Math.hypot(x2 - tap.ox, y2 - tap.oy) > TAP_CANCEL_PX) {
tap.el.classList.remove("cg-tap-armed");
this.taps.delete(hand);
this.status.textContent = "on \u2014 pinch a tab or button";
}
}
onDrop(hand) {
const id = this.grabbedByHand.get(hand);
this.grabbedByHand.delete(hand);
if (!id) return;
document.querySelector(`${TAB_SELECTOR}[data-id="${cssEscape(id)}"]`)?.classList.remove("cg-grabbed");
const app = window.app;
if (app?.detachSession) {
app.detachSession(id);
this.flash(`detached`);
} else {
this.flash("app.detachSession unavailable");
console.warn("[gesture] window.app.detachSession not found");
const grab = this.grabs.get(hand);
if (grab) {
this.grabs.delete(hand);
grab.ghost.remove();
grab.tab.classList.remove("cg-grabbed");
if (grab.armed) this.detach(grab.id);
else this.flash("cancelled");
return;
}
const tap = this.taps.get(hand);
if (tap) {
this.taps.delete(hand);
tap.el.classList.remove("cg-tap-armed");
tap.el.click();
this.flash(tap.label.toLowerCase());
}
}
/** Undock a session by calling Codeman's own wired entry point directly. */
detach(id) {
const app = window.app;
if (app && typeof app.detachSession === "function") {
app.detachSession(id);
this.flash("undocked");
return;
}
this.flash("undock unavailable");
console.warn(
"[gesture] window.app.detachSession not found \u2014 is Codeman on the session-detach build?"
);
}
positionGhost(grab, x2, y2) {
grab.ghost.style.left = `${x2}px`;
grab.ghost.style.top = `${y2}px`;
}
cancelAllGrabs() {
for (const grab of this.grabs.values()) {
grab.ghost.remove();
grab.tab.classList.remove("cg-grabbed");
}
this.grabs.clear();
for (const tap of this.taps.values()) tap.el.classList.remove("cg-tap-armed");
this.taps.clear();
document.querySelectorAll(`${TAB_SELECTOR}.cg-grabbed, .cg-tap-armed`).forEach((t2) => t2.classList.remove("cg-grabbed", "cg-tap-armed"));
}
onStatus(fps, hands) {
const { width, height } = this.canvas;
@@ -4555,14 +4655,13 @@ var GestureBridge = class {
this.ctx.fill();
this.ctx.globalAlpha = 1;
}
if (this.running) this.status.textContent = `on \xB7 ${fps}fps`;
if (this.running && this.grabs.size === 0 && this.taps.size === 0) {
this.status.textContent = `on \xB7 ${fps}fps`;
}
}
flash(msg) {
this.status.textContent = msg;
}
clearHighlights() {
document.querySelectorAll(`${TAB_SELECTOR}.cg-grabbed`).forEach((t2) => t2.classList.remove("cg-grabbed"));
}
sizeCanvas() {
const dpr = window.devicePixelRatio || 1;
this.canvas.width = Math.floor(window.innerWidth * dpr);
@@ -4574,9 +4673,20 @@ function el(tag, className) {
node.className = className;
return node;
}
function cssEscape(s2) {
const cssApi = window.CSS;
return cssApi?.escape ? cssApi.escape(s2) : s2.replace(/["\\]/g, "\\$&");
function describeError(err) {
if (err instanceof Error) return err.message || err.name || "Error";
if (typeof err === "string") return err;
if (typeof err === "number") return `code ${err}`;
if (err && typeof err === "object") {
const m2 = err.message;
if (typeof m2 === "string" && m2) return m2;
try {
return JSON.stringify(err);
} catch {
return Object.prototype.toString.call(err);
}
}
return String(err);
}
function injectStyles() {
if (document.getElementById("cg-styles")) return;
@@ -4584,22 +4694,36 @@ function injectStyles() {
.cg-surface, .cg-canvas { position: fixed; inset: 0; pointer-events: none; }
.cg-surface { z-index: ${Z2}; }
.cg-canvas { z-index: ${Z2 + 1}; width: 100vw; height: 100vh; }
.cg-preview {
position: fixed; right: 12px; bottom: 12px; width: 200px; height: 112px;
object-fit: cover; transform: scaleX(-1); border-radius: 8px; z-index: ${Z2 + 2};
box-shadow: 0 4px 16px rgba(0,0,0,.5); pointer-events: none; background: #000;
.cg-preview { transform: scaleX(-1); pointer-events: none; background: #000; }
.cg-preview.cg-pip {
position: fixed; right: 12px; bottom: 12px; width: 240px; height: 135px;
object-fit: cover; border-radius: 8px; z-index: ${Z2 + 2};
box-shadow: 0 4px 16px rgba(0,0,0,.5); opacity: 1;
}
.cg-preview.cg-full {
position: fixed; inset: 0; width: 100vw; height: 100vh;
object-fit: cover; opacity: .28; z-index: ${Z2};
}
.cg-ghost {
position: fixed; left: 0; top: 0; transform: translate(-50%, -50%) scale(1.06);
z-index: ${Z2 + 2}; pointer-events: none; opacity: .92;
box-shadow: 0 8px 28px rgba(0,0,0,.55); border-radius: 8px;
outline: 2px solid #38bdf8; outline-offset: -2px;
}
.cg-ghost.cg-armed { outline-color: #4ade80; box-shadow: 0 8px 28px rgba(74,222,128,.5); }
.cg-dock {
position: fixed; right: 12px; bottom: 132px; z-index: ${Z2 + 3};
position: fixed; right: 12px; bottom: 156px; z-index: ${Z2 + 3};
display: flex; align-items: center; gap: 8px; font: 12px/1 system-ui, sans-serif;
}
.cg-btn {
padding: 6px 12px; border-radius: 6px; border: 1px solid #3a3a40;
background: #1b1b1f; color: #e5e5e7; cursor: pointer;
}
.cg-btn-icon { padding: 6px 9px; }
.cg-btn.on { background: #16331f; border-color: #2f6b41; color: #4ade80; }
.cg-status { color: #9aa0a6; }
.session-tab.cg-grabbed { outline: 2px solid #4ade80; outline-offset: -2px; }
.cg-status { color: #9aa0a6; max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.session-tab.cg-grabbed { opacity: .35; outline: 2px dashed #4ade80; outline-offset: -2px; }
.cg-tap-armed { outline: 2px solid #4ade80 !important; outline-offset: 2px; box-shadow: 0 0 0 4px rgba(74,222,128,.25) !important; }
`;
const style = document.createElement("style");
style.id = "cg-styles";
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+17 -2
View File
@@ -36,7 +36,7 @@ import fastifyMultipart from '@fastify/multipart';
import { startPasteImageGc } from './paste-image-gc.js';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { existsSync, mkdirSync, readFileSync, chmodSync, rmSync } from 'node:fs';
import { existsSync, mkdirSync, readFileSync, chmodSync, rmSync, statSync } from 'node:fs';
import fs from 'node:fs/promises';
import { execSync } from 'node:child_process';
import { hostname as getHostname } from 'node:os';
@@ -1012,11 +1012,26 @@ export class WebServer extends EventEmitter {
// same-origin from /gesture/ so 'self' covers it; CSP is widened to match in
// registerSecurityHeaders under the same flag.
if (!soloSessionId && process.env.CODEMAN_GESTURE === '1') {
html = html.replace('</head>', `<script type="module" src="/gesture/gesture-codeman.js"></script>\n</head>`);
const v = this.gestureBundleVersion();
html = html.replace('</head>', `<script type="module" src="/gesture/gesture-codeman.js${v}"></script>\n</head>`);
}
return html;
}
/** Cache-busting query for the gesture bundle: its mtime, re-read per render.
* The bundle is served from /gesture/ with a 1-year cache, so without a
* version that changes on redeploy the browser would keep running a stale
* bundle forever. Re-stat'ing each render means a freshly copied-in bundle is
* picked up with no server restart. Empty string if the file is missing. */
private gestureBundleVersion(): string {
try {
const p = join(__dirname, 'public', 'gesture', 'gesture-codeman.js');
return `?v=${Math.floor(statSync(p).mtimeMs)}`;
} catch {
return '';
}
}
private async setupSessionListeners(session: Session): Promise<void> {
// Create run summary tracker for this session
const summaryTracker = new RunSummaryTracker(session.id, session.name);