← Commit history

Interactive-surface phase 1+2 core (plan: docs/PLAN-interactive-surface.md). Annotation captures are now TIGHT-CROPPED to the drawn strokes plus a 48px margin instead of the whole browser view (John: if the user wants the whole view they'll draw a circle around it), Photoshop-style tool cursors with the pen carrying a live swatch of the selected color, a quick-capture Auto mode that fires the moment the mouse lifts, and the clipboard-first return path: every capture lands in the Windows clipboard as image plus text JSON sidecar together via a .NET DataObject helper so one paste into any AI chat carries both, with a caption bar baked under the crop and an iTXt metadata chunk inside the PNG so an image-only paste still carries context. An in-page toast shows a thumbnail of the exact crop grabbed. Also: USER_CALLERS set replaces the four exact-string user-taskbar-menu compares (the toolbar extension's user-toolbar caller joins it), and the toolbarExtension settings family lands ahead of the extension

John Lauer ·fbc1bd2fdd ·2mo ago ·parent a3bd8fe
4 files changed +237−51
adom-bridge-puppeteer-manifest.json+4−4
@@ -1,10 +1,10 @@ {   "manifest_version": 1,   "name": "puppeteer",-  "version": "2.0.49",-  "url": "https://wiki.adom.inc/download/adom/pup-bridge/2.0.49/adom-bridge-puppeteer-v2.0.49.zip",-  "sha256": "c79a022610c6174260024d1de1a89747b1d38c7542829b985e3c98847a06b7a2",-  "size": 654831,+  "version": "2.0.50",+  "url": "https://wiki.adom.inc/download/adom/pup-bridge/2.0.50/adom-bridge-puppeteer-v2.0.50.zip",+  "sha256": "4404efaf1bb53ed3fd81d139f3b2201cc43dc6efc97657e37aa95adcb0ef46a6",+  "size": 659091,   "verbPrefixes": [     "browser_",     "desktop_recorder_",
src/BRIDGE_VERSION+1−1
@@ -1 +1 @@-2.0.49+2.0.50
src/bridge.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "puppeteer",   "displayName": "Puppeteer (Chrome for Testing + Native)",-  "version": "2.0.49",+  "version": "2.0.50",   "description": "Drives a browser ALREADY on the machine \u2014 installed Chrome, else Microsoft Edge (every Windows PC ships with Edge) \u2014 launched with a FRESH isolated profile, so the common case has NO ~150 MB Chrome-for-Testing download and it never touches the user's real tabs/logins. Binds its local server to LOOPBACK (127.0.0.1) only, so no Windows Firewall 'allow Node through the firewall' prompt ever pops. Chrome for Testing is fetched only as a LAST RESORT if the machine has no Chromium browser at all. SPAWN-VERIFIED fallback: pup tries each detected browser (Chrome \u2192 Edge \u2192 CfT) and actually launches it, falling through to the next if one won't start (and handling browsers like Edge that relaunch onto another PID), so a corrupt/partial install never hard-fails an open. The browser that launches is cached as the default (persisted across restarts) so it isn't re-probed each time. On spawn the bridge SELF-WARMS: detects browsers + disk, and only if the box has no Chrome/Edge and no cached CfT does it background-fetch CfT so the first open is instant. pup_use pins a specific browser (chrome | edge | cft | auto); {browser:'chrome',install:true} downloads & installs REAL Google Chrome (user-scoped, no admin) on an Edge-only box. pup_readiness (READ-ONLY) reports which browser will be used + the full candidate list + the default + free disk. Every failure returns a specific errorCode + _hint (chrome_install_no_disk, chrome_download_network, chrome_install_permission, chrome_stable_not_detected, \u2026). Launches with FULL browser permissions by default for full debug (strictPermissions:true opts out). Headful automation: open/close windows+tabs, navigate, screenshot, eval JS, record. pup is a GENERIC, non-profile browser only \u2014 every session is a fresh isolated Chrome/Edge process, never the user's real signed-in browser; profile defaults to sessionId. To drive the user's REAL Chrome/Edge (their profile, cookies, SSO), use the Adom browser EXTENSION's nbrowser_* verbs \u2014 a separate bridge, not pup's job. macOS (v1.9.74+): full parity with mac-native mechanics \u2014 background-by-default is the app-level hide (windows stay drivable/screenshottable while hidden), raise/lower map to unhide+activate/hide, pup_alert_window posts a native toast whose click focuses the window, pup_use {browser:'chrome',install:true} installs Chrome from the official dmg, and taskbar identity/badging remains Windows-only.",   "homepage": "https://wiki.adom.inc/adom/adom-bridge",   "author": "Adom Inc.",
src/server.js+231−45
@@ -202,6 +202,11 @@ const PORT = (() => {   return idx !== -1 && process.argv[idx + 1] ? parseInt(process.argv[idx + 1]) : 8851; })(); +// v2.0.50: HUMAN-INITIATED caller identities (taskbar jump-list clicks, the toolbar-extension+// popup). Exempt from the ownership gate (a human operating their own window is always+// sanctioned) and never recorded as a window's owner. Was 4 exact-string compares on+// 'user-taskbar-menu'; the toolbar extension adds 'user-toolbar'.+const USER_CALLERS = new Set(['user-taskbar-menu', 'user-toolbar', 'user-pill']); const BRIDGE_VERSION = (() => { try { return fs.readFileSync(path.join(__dirname, 'BRIDGE_VERSION'), 'utf8').trim(); } catch { return 'unknown'; } })();  const SHOTS_DIR = path.join(__dirname, 'screenshots');@@ -3050,6 +3055,8 @@ function pupSettings() {   // v2.0.42 (John: "can you add a jump list to open the pup dashboard?")   D.jlDashboard = 'on';   D.jlAnnotate = 'on';   // v2.0.49+  // v2.0.50 (plan: interactive surface): caption bar baked under captures; toolbar extension family+  D.annCaptionBar = 'on'; D.toolbarExtension = 'on'; D.tbOwnerBadge = 'on'; D.tbSnipHotkey = 'on';   // v1.9.293 (John): the 'NOT foregrounded by Adom' caption is now OFF by default. He is right that   // input-timing cannot reliably tell his own raises (click / alt+tab / window click) from a rare   // JS raise - and Windows blocks most background foreground-steals anyway, so the case it exists@@ -5256,26 +5263,46 @@ function appIconReprimand(url, hadFavicon) { // click felt like nothing happened at all. Same `id` on every call so the start caption is // REPLACED by the result caption instead of stacking. Best-effort; never throws. -// ─── v2.0.49 ANNOTATION (John: "claude desktop has annotate in its browser — add it to pup"):-// an in-page canvas overlay the HUMAN draws on (pen/arrow/box/text), started from the window's-// jump list or the pup_annotate verb. pup's twist over Claude Desktop: the window is THREAD-OWNED,-// so a Done'd annotation is a MESSAGE — the drawing stays in-DOM, so the owning AI thread's next-// screenshot shows exactly what the human circled. Injected via CDP (immune to page CSP), zero-// external resources.+// ─── v2.0.50 ANNOTATION (plan: docs/PLAN-interactive-surface.md). Human draws on the page;+// captures are TIGHT-CROPPED to the drawn strokes (+margin — "if the user wants the whole view,+// they'll draw a circle around it"), QUICK MODE captures on every mouse-up, and each capture is+// copied to the Windows clipboard as image + text/plain JSON sidecar so one paste into any AI+// chat carries both. A preview toast shows the exact crop so the user can verify the grab.+// Injected via CDP (CSP-immune), zero external resources; no backticks/interpolation inside the+// overlay string (it lives in a template literal). const ANNOTATE_OVERLAY_JS = `(() => {   if (window.__pupAnnotate) { window.__pupAnnotate.show(); return 'shown'; }-  var S = { tool: 'pen', color: '#ff4d5e', strokes: [], cur: null };+  var initTool = window.__pupAnnotateTool || 'pen'; window.__pupAnnotateTool = null;+  var S = { tool: 'pen', color: '#ff4d5e', strokes: [], cur: null, quick: initTool === 'snip', seq: 0 };   var COLORS = ['#ff4d5e', '#ffd23f', '#39d98a', '#38bdf8', '#c084fc', '#ffffff'];   var wrap = document.createElement('div');   wrap.id = '__pup_annotate';   wrap.style.cssText = 'position:fixed;inset:0;z-index:2147483646;pointer-events:none;';   var cv = document.createElement('canvas');-  cv.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;pointer-events:auto;cursor:crosshair;touch-action:none;';+  cv.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;pointer-events:auto;touch-action:none;';   wrap.appendChild(cv);   var bar = document.createElement('div');   bar.style.cssText = 'position:fixed;top:14px;left:50%;transform:translateX(-50%);display:flex;gap:6px;align-items:center;background:#111827ee;border:1px solid #2dd4bf55;border-radius:999px;padding:7px 12px;pointer-events:auto;font:13px system-ui,sans-serif;color:#e5e7eb;box-shadow:0 6px 24px #000a;';   wrap.appendChild(bar);   var ctx = cv.getContext('2d');+  // ── Photoshop-style tool cursors: SVG data-URI, hotspot at the working tip, pen carries a+  // live swatch of the current color (rebuilt on color change).+  function penCur() {+    var svg = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24">'+      + '<path d="M5 19 L6.5 14.5 L16 5 a2 2 0 0 1 2.8 2.8 L9.5 17.5 Z" fill="#e5e7eb" stroke="#111" stroke-width="1.2"/>'+      + '<path d="M5 19 L6.5 14.5 L9.5 17.5 Z" fill="' + S.color + '" stroke="#111" stroke-width="0.8"/>'+      + '<circle cx="19.5" cy="19.5" r="3.5" fill="' + S.color + '" stroke="#fff" stroke-width="1"/></svg>';+    return 'url("data:image/svg+xml,' + encodeURIComponent(svg) + '") 5 19, crosshair';+  }+  function arrowCur() {+    var svg = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24">'+      + '<path d="M2 2 L11 20 L13.5 13.5 L20 11 Z" fill="' + S.color + '" stroke="#fff" stroke-width="1.4"/></svg>';+    return 'url("data:image/svg+xml,' + encodeURIComponent(svg) + '") 2 2, crosshair';+  }+  function setCursor() {+    cv.style.cursor = S.tool === 'pen' ? penCur() : S.tool === 'arrow' ? arrowCur()+      : S.tool === 'text' ? 'text' : 'crosshair';+  }   function fit() { var d = window.devicePixelRatio || 1; cv.width = innerWidth * d; cv.height = innerHeight * d; ctx.setTransform(d, 0, 0, d, 0, 0); redraw(); }   function drawStroke(st) {     ctx.strokeStyle = st.color; ctx.fillStyle = st.color; ctx.lineWidth = 3.5; ctx.lineCap = 'round'; ctx.lineJoin = 'round';@@ -5291,8 +5318,31 @@ const ANNOTATE_OVERLAY_JS = `(() => {     else if (st.tool === 'snip') { var s1 = st.pts[0], s2 = st.pts[st.pts.length - 1]; ctx.save(); ctx.setLineDash([7, 5]); ctx.lineWidth = 2; ctx.strokeStyle = '#5eead4'; ctx.strokeRect(Math.min(s1[0],s2[0]), Math.min(s1[1],s2[1]), Math.abs(s2[0]-s1[0]), Math.abs(s2[1]-s1[1])); ctx.restore(); }   }   function redraw() { ctx.clearRect(0, 0, innerWidth, innerHeight); S.strokes.forEach(drawStroke); if (S.cur) drawStroke(S.cur); }+  // Tight-crop bbox over ALL strokes, padded and clamped to the viewport (1a2).+  function bbox() {+    if (!S.strokes.length) return null;+    var x1 = 1e9, y1 = 1e9, x2 = -1e9, y2 = -1e9;+    S.strokes.forEach(function (st) {+      if (st.tool === 'text') {+        var w = (st.text || '').length * 9.5, p0 = st.pts[0];+        x1 = Math.min(x1, p0[0]); y1 = Math.min(y1, p0[1] - 18); x2 = Math.max(x2, p0[0] + w); y2 = Math.max(y2, p0[1] + 6);+      } else st.pts.forEach(function (p) { x1 = Math.min(x1, p[0]); y1 = Math.min(y1, p[1]); x2 = Math.max(x2, p[0]); y2 = Math.max(y2, p[1]); });+    });+    var M = 48;+    x1 = Math.max(0, x1 - M); y1 = Math.max(0, y1 - M);+    x2 = Math.min(innerWidth, x2 + M); y2 = Math.min(innerHeight, y2 + M);+    if (x2 - x1 < 24 || y2 - y1 < 24) return null;+    return { x: Math.round(x1), y: Math.round(y1), w: Math.round(x2 - x1), h: Math.round(y2 - y1) };+  }+  function emit(extra, hide) {+    S.seq++;+    var r = { done: true, shapes: S.strokes.length, ts: Date.now(), seq: S.seq, bbox: bbox() };+    if (extra) for (var k in extra) r[k] = extra[k];+    if (hide) { bar.style.display = 'none'; cv.style.pointerEvents = 'none'; }+    window.__pupAnnotateResult = r;+  }   cv.addEventListener('pointerdown', function (e) {-    if (S.tool === 'text') { var t = prompt('Annotation text:'); if (t) { S.strokes.push({ tool: 'text', color: S.color, pts: [[e.clientX, e.clientY]], text: t }); redraw(); } return; }+    if (S.tool === 'text') { var t = prompt('Annotation text:'); if (t) { S.strokes.push({ tool: 'text', color: S.color, pts: [[e.clientX, e.clientY]], text: t }); redraw(); if (S.quick) emit(null, false); } return; }     S.cur = { tool: S.tool, color: S.color, pts: [[e.clientX, e.clientY]] }; cv.setPointerCapture(e.pointerId);   });   cv.addEventListener('pointermove', function (e) { if (S.cur) { S.cur.pts.push([e.clientX, e.clientY]); redraw(); } });@@ -5302,71 +5352,202 @@ const ANNOTATE_OVERLAY_JS = `(() => {       var p1 = S.cur.pts[0], p2 = S.cur.pts[S.cur.pts.length - 1];       var r = { x: Math.min(p1[0], p2[0]), y: Math.min(p1[1], p2[1]), w: Math.abs(p2[0]-p1[0]), h: Math.abs(p2[1]-p1[1]) };       S.cur = null; redraw();-      if (r.w > 8 && r.h > 8) { bar.style.display = 'none'; cv.style.pointerEvents = 'none'; window.__pupAnnotateResult = { done: true, snip: r, shapes: S.strokes.length, ts: Date.now() }; }+      if (r.w > 8 && r.h > 8) emit({ snip: { x: Math.round(r.x), y: Math.round(r.y), w: Math.round(r.w), h: Math.round(r.h) } }, true);       return;     }     S.strokes.push(S.cur); S.cur = null; redraw();+    // QUICK MODE (1a3): the moment the mouse lifts, capture fires — keep drawing and each lift+    // supersedes the last capture with the union bbox.+    if (S.quick) emit(null, false);   });   function btn(label, title, fn, id) {     var b = document.createElement('button');     b.textContent = label; b.title = title;     b.style.cssText = 'background:none;border:1px solid transparent;border-radius:8px;color:#e5e7eb;padding:3px 8px;cursor:pointer;font:inherit;';     b.onmouseenter = function () { b.style.background = '#ffffff14'; };-    b.onmouseleave = function () { b.style.background = S.tool === id ? '#2dd4bf22' : 'none'; };+    b.onmouseleave = function () { b.style.background = S.tool === b.dataset.tool ? '#2dd4bf22' : 'none'; };     b.onclick = fn; if (id) b.dataset.tool = id; bar.appendChild(b); return b;   }-  function selTool(id) { S.tool = id; [].forEach.call(bar.querySelectorAll('button[data-tool]'), function (x) { x.style.background = x.dataset.tool === id ? '#2dd4bf22' : 'none'; x.style.borderColor = x.dataset.tool === id ? '#2dd4bf66' : 'transparent'; }); }+  function selTool(id) { S.tool = id; setCursor(); [].forEach.call(bar.querySelectorAll('button[data-tool]'), function (x) { x.style.background = x.dataset.tool === id ? '#2dd4bf22' : 'none'; x.style.borderColor = x.dataset.tool === id ? '#2dd4bf66' : 'transparent'; }); }   btn('✎ Pen', 'freehand', function () { selTool('pen'); }, 'pen');   btn('↗ Arrow', 'arrow', function () { selTool('arrow'); }, 'arrow');   btn('▭ Box', 'rectangle', function () { selTool('box'); }, 'box');   btn('T Text', 'text note', function () { selTool('text'); }, 'text');-  btn('\u25a3 Snip', 'marquee a region — captures it immediately (like Win+Shift+S)', function () { selTool('snip'); }, 'snip');+  btn('▣ Snip', 'marquee a region — captures it immediately (like Win+Shift+S)', function () { selTool('snip'); }, 'snip');   var dot = document.createElement('button');   dot.title = 'color'; dot.style.cssText = 'width:20px;height:20px;border-radius:50%;border:2px solid #fff8;cursor:pointer;background:' + S.color + ';';-  dot.onclick = function () { var i = (COLORS.indexOf(S.color) + 1) % COLORS.length; S.color = COLORS[i]; dot.style.background = S.color; };+  dot.onclick = function () { var i = (COLORS.indexOf(S.color) + 1) % COLORS.length; S.color = COLORS[i]; dot.style.background = S.color; setCursor(); };   bar.appendChild(dot);+  var qk = document.createElement('button');+  function qkPaint() { qk.style.cssText = 'background:' + (S.quick ? '#2dd4bf22' : 'none') + ';border:1px solid ' + (S.quick ? '#2dd4bf88' : 'transparent') + ';border-radius:8px;color:' + (S.quick ? '#5eead4' : '#e5e7eb') + ';padding:3px 8px;cursor:pointer;font:inherit;'; }+  qk.textContent = '⚡ Auto'; qk.title = 'Quick capture: copy to clipboard the moment you lift the mouse';+  qk.onclick = function () { S.quick = !S.quick; qkPaint(); };+  qkPaint(); bar.appendChild(qk);   btn('↶ Undo', 'undo last', function () { S.strokes.pop(); redraw(); });   btn('✕ Clear', 'clear all', function () { S.strokes = []; redraw(); });-  var done = btn('✓ Done', 'capture for the owning AI thread', function () {-    bar.style.display = 'none'; cv.style.pointerEvents = 'none';-    window.__pupAnnotateResult = { done: true, shapes: S.strokes.length, ts: Date.now() };-  });+  var done = btn('✓ Done', 'capture for the owning AI thread', function () { emit(null, true); });   done.style.cssText += 'background:#2dd4bf22;border:1px solid #2dd4bf88;color:#5eead4;font-weight:600;';   btn('✖', 'cancel (remove drawing)', function () { wrap.remove(); window.__pupAnnotate = null; window.__pupAnnotateResult = { done: false, ts: Date.now() }; });   window.__pupAnnotate = { show: function () { wrap.style.display = ''; bar.style.display = 'flex'; cv.style.pointerEvents = 'auto'; } };   addEventListener('resize', fit);   document.documentElement.appendChild(wrap);-  fit(); selTool('pen');+  fit(); selTool(initTool);   return 'started'; })()`; -async function startAnnotation(session, sessionId, callerThread) {+// crc32 (fallback when zlib.crc32 is unavailable, node <20.15)+let _crcTable = null;+function _crc32(buf) {+  const zlib = require('zlib');+  if (typeof zlib.crc32 === 'function') return zlib.crc32(buf) >>> 0;+  if (!_crcTable) {+    _crcTable = new Uint32Array(256);+    for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1); _crcTable[n] = c >>> 0; }+  }+  let c = 0xFFFFFFFF;+  for (let i = 0; i < buf.length; i++) c = _crcTable[(c ^ buf[i]) & 0xFF] ^ (c >>> 8);+  return (c ^ 0xFFFFFFFF) >>> 0;+}+// Insert an iTXt chunk (keyword + UTF-8 JSON) before IEND — the machine-readable sidecar that+// rides INSIDE the PNG, for tooling that reads metadata (sharp strips unknown chunks on+// re-encode, so this runs LAST, after any caption-bar composite).+function pngAddITXt(buf, keyword, text) {+  try {+    const iend = buf.lastIndexOf(Buffer.from([0x49, 0x45, 0x4E, 0x44]));+    if (iend < 8) return buf;+    const kw = Buffer.from(keyword, 'latin1');+    const data = Buffer.concat([kw, Buffer.from([0, 0, 0, 0, 0]), Buffer.from(text, 'utf8')]);+    const type = Buffer.from('iTXt', 'latin1');+    const len = Buffer.alloc(4); len.writeUInt32BE(data.length);+    const crc = Buffer.alloc(4); crc.writeUInt32BE(_crc32(Buffer.concat([type, data])));+    const chunk = Buffer.concat([len, type, data, crc]);+    const chunkStart = iend - 4;+    return Buffer.concat([buf.slice(0, chunkStart), chunk, buf.slice(chunkStart)]);+  } catch (e) { return buf; }+}+// Caption bar (phase 2): a thin strip baked UNDER the crop so any AI the image is pasted into+// sees the context — visible sidecar, immune to clipboard format games. Toggle: annCaptionBar.+async function addCaptionBar(buf, caption) {+  try {+    const sharp = require('sharp');+    const meta = await sharp(buf).metadata();+    const W = meta.width, BAR = 26;+    const esc = String(caption).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/"/g, '&quot;');+    const svg = Buffer.from('<svg xmlns="http://www.w3.org/2000/svg" width="' + W + '" height="' + BAR + '">'+      + '<rect width="100%" height="100%" fill="#0d1117"/>'+      + '<text x="8" y="17" font-family="Segoe UI, system-ui, sans-serif" font-size="12" fill="#7fe9e2">' + esc + '</text></svg>');+    return await sharp(buf)+      .extend({ bottom: BAR, background: '#0d1117' })+      .composite([{ input: svg, top: meta.height, left: 0 }])+      .png().toBuffer();+  } catch (e) { return buf; }+}+// Multi-format clipboard: image + text/plain TOGETHER via one .NET DataObject (plain+// Set-Clipboard cannot do multi-format). One paste into a chat input attaches the image AND+// inserts the sidecar text; custom mime formats would NOT survive into web inputs.+function pupClipHelperPath() {+  const la = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');+  return path.join(la, 'Adom Bridge', 'pup-clip.ps1');+}+function ensurePupClipHelper() {+  if (process.platform !== 'win32') return null;+  const p2 = pupClipHelperPath();+  const script = `param([Parameter(Mandatory=$true)][string]$Png, [string]$Txt='')+Add-Type -AssemblyName System.Windows.Forms+Add-Type -AssemblyName System.Drawing+$img = [System.Drawing.Image]::FromFile($Png)+$d = New-Object System.Windows.Forms.DataObject+$d.SetImage($img)+if ($Txt -and (Test-Path $Txt)) { $d.SetText([IO.File]::ReadAllText($Txt)) }+[System.Windows.Forms.Clipboard]::SetDataObject($d, $true)+$img.Dispose()+`;+  try { let cur = null; try { cur = fs.readFileSync(p2, 'utf8'); } catch (e) {}+    if (cur !== script) { fs.mkdirSync(path.dirname(p2), { recursive: true }); fs.writeFileSync(p2, script); } } catch (e) { return null; }+  return p2;+}+function copyCaptureToClipboard(pngPath, sidecarText) {+  return new Promise((resolve) => {+    try {+      const helper = ensurePupClipHelper();+      if (!helper) return resolve(false);+      const txtPath = pngPath.replace(/\.png$/i, '.txt');+      fs.writeFileSync(txtPath, sidecarText);+      const { execFile } = require('child_process');+      execFile('powershell.exe', ['-STA', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', helper, pngPath, txtPath],+        { timeout: 15000, windowsHide: true }, (err) => resolve(!err));+    } catch (e) { resolve(false); }+  });+}+// In-page confirmation toast WITH a thumbnail of the ACTUAL captured PNG — the user sees their+// red circle and the grabbed margin before tabbing away.+async function showCaptureToast(pg, previewDataUrl, line1) {+  try {+    await pg.evaluate(function (dataUrl, txt) {+      var old = document.getElementById('__pup_ann_toast'); if (old) old.remove();+      var t = document.createElement('div'); t.id = '__pup_ann_toast';+      t.style.cssText = 'position:fixed;right:18px;bottom:18px;z-index:2147483647;background:#111827f2;border:1px solid #2dd4bf66;border-radius:12px;padding:10px;box-shadow:0 8px 30px #000c;font:12px system-ui,sans-serif;color:#e5e7eb;max-width:340px;cursor:pointer;';+      var cap = document.createElement('div'); cap.textContent = txt; cap.style.cssText = 'margin-bottom:7px;color:#5eead4;font-weight:600;';+      t.appendChild(cap);+      if (dataUrl) { var im = document.createElement('img'); im.src = dataUrl; im.style.cssText = 'display:block;max-width:320px;max-height:200px;border-radius:8px;border:1px solid #ffffff22;'; t.appendChild(im); }+      t.onclick = function () { t.remove(); };+      document.documentElement.appendChild(t);+      setTimeout(function () { try { t.remove(); } catch (e) {} }, 4500);+    }, previewDataUrl, line1);+  } catch (e) {}+}++async function startAnnotation(session, sessionId, callerThread, opts) {   const tab = (session.activeTabId && (session.tabs || []).find(t => t.tabId === session.activeTabId)) || (session.tabs || [])[0];   const pg = tab && tab.page;   if (!pg) throw new Error('no page to annotate');+  const tool = (opts && opts.tool) || 'pen';+  await pg.evaluate((t2) => { window.__pupAnnotateTool = t2; }, tool);   await pg.evaluate((code) => eval(code), ANNOTATE_OVERLAY_JS);-  console.log(`[annotate] "${sessionId}" overlay started (by ${callerThread || 'unknown'})`);-  // Fire-and-forget: wait up to 10 min for Done/cancel, then capture. The drawing STAYS in the DOM-  // after Done (pointer-events off), so the owning thread's next screenshot shows it.+  console.log(`[annotate] "${sessionId}" overlay started (by ${callerThread || 'unknown'}, tool=${tool})`);+  // Capture loop: quick-mode mouse-ups keep it alive (each lift supersedes the last capture);+  // Done/snip/cancel end it. Drawing STAYS in the DOM after Done so the owning thread's next+  // screenshot shows it.   (async () => {-    try {-      await pg.waitForFunction('window.__pupAnnotateResult', { timeout: 600000, polling: 1000 });-      const res = await pg.evaluate(() => { const r = window.__pupAnnotateResult; window.__pupAnnotateResult = null; return r; });-      if (!res || !res.done) { console.log(`[annotate] "${sessionId}" cancelled`); return; }-      const ts = new Date().toISOString().replace(/[:.]/g, '-');-      const isSnip = res.snip && res.snip.w > 8 && res.snip.h > 8;-      const fp = path.join(SHOTS_DIR, `annotate-${isSnip ? 'snip-' : ''}${sessionId}-${ts}.png`);-      // v2.0.49 SNIP (John: "i hit shift+windows+s all the time — build a screenshot snippet in"):-      // the marquee captures JUST that region (clip is viewport CSS px, same space as clientX/Y),-      // drawings inside the region included since they're in-DOM.-      await pg.screenshot(isSnip ? { path: fp, clip: { x: res.snip.x, y: res.snip.y, width: res.snip.w, height: res.snip.h } } : { path: fp });-      session._lastAnnotation = { path: fp, ts: Date.now(), shapes: res.shapes, snip: isSnip ? res.snip : null };-      console.log(`[annotate] "${sessionId}" CAPTURED ${isSnip ? `SNIP ${res.snip.w}x${res.snip.h}` : `${res.shapes} shape(s)`} -> ${fp} — drawing left in-DOM so the owning thread's next screenshot shows it`);-      wikiCaption(isSnip ? '\u25a3 Snippet captured — the AI thread owning this window can now see it' : `✎ Annotation captured (${res.shapes} shape${res.shapes === 1 ? '' : 's'}) — the AI thread owning this window can now see it`, 6000).catch(() => {});-    } catch (e) { console.log(`[annotate] "${sessionId}" wait/capture ended: ${e.message}`); }+    const t0 = Date.now();+    let lastSeq = 0;+    while (Date.now() - t0 < 15 * 60000) {+      try {+        await pg.waitForFunction('window.__pupAnnotateResult', { timeout: 15 * 60000 - (Date.now() - t0), polling: 800 });+      } catch (e) { console.log(`[annotate] "${sessionId}" wait ended: ${e.message}`); return; }+      let res = null;+      try { res = await pg.evaluate(() => { const r = window.__pupAnnotateResult; window.__pupAnnotateResult = null; return r; }); } catch (e) { return; }+      if (!res) continue;+      if (!res.done) { console.log(`[annotate] "${sessionId}" cancelled`); return; }+      if (res.seq && res.seq <= lastSeq) { if (!res.quick) return; continue; }+      lastSeq = res.seq || (lastSeq + 1);+      try {+        const ts = new Date().toISOString().replace(/[:.]/g, '-');+        const isSnip = res.snip && res.snip.w > 8 && res.snip.h > 8;+        const clipRect = isSnip ? res.snip : res.bbox;   // tight-crop (1a2) unless nothing drawn+        const fp = path.join(SHOTS_DIR, `annotate-${isSnip ? 'snip-' : ''}${sessionId}-${ts}.png`);+        let buf = await pg.screenshot(clipRect ? { clip: { x: clipRect.x, y: clipRect.y, width: clipRect.w, height: clipRect.h } } : {});+        const owner = session.owner || session._ownerThread || 'unknown-thread';+        let url = ''; try { url = pg.url() || ''; } catch (e) {}+        const shortUrl = url.replace(/^https?:\/\//, '').slice(0, 80);+        const when = new Date().toTimeString().slice(0, 5);+        if (pupSettings().annCaptionBar !== 'off') buf = await addCaptionBar(buf, `pup · ${owner}'s window · ${shortUrl} · ${when}`);+        const sidecar = { kind: 'pup-annotation', thread: owner, container: session._callerContainer || null, sessionId, url, ts: new Date().toISOString(), shapes: res.shapes || 0, crop: clipRect || 'full', viewport: null };+        buf = pngAddITXt(buf, 'pup-annotation', JSON.stringify(sidecar));+        fs.writeFileSync(fp, buf);+        session._lastAnnotation = { path: fp, ts: Date.now(), shapes: res.shapes, snip: isSnip ? res.snip : null };+        const sidecarText = `pup snippet — ${owner}'s window — ${url} — ${sidecar.ts}\n` + JSON.stringify(sidecar, null, 1);+        const clipped = await copyCaptureToClipboard(fp, sidecarText);+        // preview thumbnail for the toast (resized so the data URL stays small)+        let preview = null;+        try { const sharp = require('sharp'); const small = await sharp(buf).resize({ width: 320, withoutEnlargement: true }).png().toBuffer(); preview = 'data:image/png;base64,' + small.toString('base64'); } catch (e) {}+        await showCaptureToast(pg, preview, `${isSnip ? '▣ Snippet' : '✎ Annotation'} ${clipped ? 'copied to your clipboard' : 'captured'}`);+        console.log(`[annotate] "${sessionId}" CAPTURED ${isSnip ? `SNIP ${res.snip.w}x${res.snip.h}` : (clipRect ? `tight-crop ${clipRect.w}x${clipRect.h}` : 'full-view')} -> ${fp} | clipboard=${clipped} | quick=${!!res.quick}`);+      } catch (e) { console.log(`[annotate] "${sessionId}" capture failed: ${e.message}`); }+      if (!res.quick) return;   // Done/snip end the loop; quick mode re-arms+    }   })(); }- async function wikiCaption(text, expiresInMs) {   try {     // v1.9.11: AD ≥1.9.144 exposes desktop_caption on the LOOPBACK direct API (the path bridges@@ -9809,6 +9990,10 @@ const SETTINGS_DEF=[  {k:'jlCloseAll',l:'',d:'The Close all pup windows task on every pup icon.',dep:'jumpLists',subtgl:1,img:'jlcloseall'},  {k:'jlDashboard',l:'',d:'The Open pup dashboard task on every pup icon: one click opens the live window/tab/thread map.',dep:'jumpLists',subtgl:1},  {k:'jlAnnotate',l:'',d:'The Annotate this page task: draw on the page; Done captures it for the AI thread that owns the window.',dep:'jumpLists',subtgl:1},+ {k:'annCaptionBar',l:'Caption bar on captures',d:'Bake a thin context strip under every annotation/snip capture (thread, page, time) so any AI you paste it into sees where it came from.'},+ {k:'toolbarExtension',l:'Adom toolbar in Chrome',d:'The Adom Pup extension button in the browser toolbar (annotate, snip, view toggle, dashboard, owner, close). Requires the one-time extension install.'},+ {k:'tbOwnerBadge',l:'',d:'Per-tab colored badge on the toolbar icon showing which AI thread owns the window.',dep:'toolbarExtension',subtgl:1},+ {k:'tbSnipHotkey',l:'',d:'Ctrl+Shift+A opens snip on the current pup window.',dep:'toolbarExtension',subtgl:1},  {k:'fgWatch',l:'20s foreground watch',d:'For 20s after a window opens or navigates in the background, watch whether something (JavaScript, Chrome, a focus change) brings it to the foreground, and caption that it was not Adom. Never moves the window.'},  {k:'fgCaption',l:'Caption on AI foreground',d:'When an AI brings a window to your screen, show its stated reason as an on-screen caption. The AI is always forced to give a reason; this shows it to you.'},  {k:'fgCaptionMs',l:'',d:'',slider:[500,5000,250],dep:'fgCaption',test:1},@@ -10134,7 +10319,7 @@ const server = http.createServer(async (req, res) => {             reportIssues: ['on', 'off'], autoLogin: ['on', 'off'], minimalTouch: ['on', 'off'],             taskbarGrouping: ['split', 'grouped'],             aumidIconStyle: ['standard', 'category'],-            jlViewTasks: ['on', 'off'], jlCloseAll: ['on', 'off'], jlDashboard: ['on', 'off'], jlAnnotate: ['on', 'off'],+            jlViewTasks: ['on', 'off'], jlCloseAll: ['on', 'off'], jlDashboard: ['on', 'off'], jlAnnotate: ['on', 'off'], annCaptionBar: ['on', 'off'], toolbarExtension: ['on', 'off'], tbOwnerBadge: ['on', 'off'], tbSnipHotkey: ['on', 'off'],             notMineCaption: ['on', 'off'], ownerChip: ['on', 'off'],             titleTag: ['on', 'off'], titlePrefix: ['on', 'off'],           };@@ -10460,7 +10645,7 @@ const server = http.createServer(async (req, res) => {       // ai-<thread-slug> window, else the single window the thread owns under an explicit name.       // No match is a teaching error. The old activeSessionId fallback (= whichever window ANY       // thread touched last) is gone from every session-targeting verb.-      if (args && !args.sessionId && _callerThread && _callerThread !== 'user-taskbar-menu' && SESSION_TARGETING_VERBS.has(command)) {+      if (args && !args.sessionId && _callerThread && !USER_CALLERS.has(_callerThread) && SESSION_TARGETING_VERBS.has(command)) {         const derived = threadDefaultSessionId(_callerThread);         if (sessions.has(derived)) args.sessionId = derived;         else {@@ -10488,7 +10673,7 @@ const server = http.createServer(async (req, res) => {       // as the "kicad took over nb's window" incident. Nothing mutates, nothing screenshots, the       // caller learns exactly what happened and how to recover.       if (args && typeof args.sessionId === 'string' && args.sessionId-          && command !== 'pup_open_window' && _callerThread !== 'user-taskbar-menu' && !sessions.has(args.sessionId)) {+          && command !== 'pup_open_window' && !USER_CALLERS.has(_callerThread) && !sessions.has(args.sessionId)) {         const live = [...sessions.keys()];         sendJSON(res, {           success: false,@@ -10507,7 +10692,7 @@ const server = http.createServer(async (req, res) => {         // ownership gate entirely (it is not another AI thread trying to steal a window). And         // pup_close with no sessionId is the GLOBAL close-all (close every window across every         // thread, by design) — never gate it, never resolve it to the active window.-        const _isUserTaskbar = _callerThread === 'user-taskbar-menu';+        const _isUserTaskbar = USER_CALLERS.has(_callerThread);         const _isGlobalClose = command === 'pup_close' && !(args && args.sessionId);         const OWNER_GATED = !_isUserTaskbar && !_isGlobalClose && (MUTATING_VERBS.has(command)           || ['pup_close_tab', 'pup_close_window', 'pup_close', 'pup_configure', 'pup_focus_window',@@ -10982,6 +11167,7 @@ const server = http.createServer(async (req, res) => {             session._lastThread = _callerThread;             session._lastContainer = _callerContainer;             if (_callerReason) session._openReason = String(_callerReason).slice(0, 300);+            try { if (_callerContainer) session._callerContainer = _callerContainer; } catch (e) {}             console.log(`[caller] "${sessionId}" owned by AI thread "${_callerThread}"${_callerContainer ? ` (${_callerContainer})` : ''}${_callerReason ? ` — reason: ${String(_callerReason).slice(0, 90)}` : ''}`);             // v1.9.103: put the thread FIRST in the window title, which is the text Windows draws above             // the taskbar hover-preview. Kept SHORT (18 chars) on purpose: that header truncates around@@ -11576,7 +11762,7 @@ const server = http.createServer(async (req, res) => {             // path below, which is a genuine reload that GUARANTEES the page is in that view (for authed it             // re-mints + navigates, i.e. actually signs it in). Only a PROGRAMMATIC same-view call short-             // circuits with bringToFront.-            const _userClick = _callerThread === 'user-taskbar-menu' || ((args.caller && args.caller.aiThread) === 'user-taskbar-menu');+            const _userClick = USER_CALLERS.has(_callerThread) || USER_CALLERS.has(args.caller && args.caller.aiThread);             if (!_userClick) {               session._foreground = true;               try { await session.page.bringToFront(); } catch (e) {}@@ -13664,7 +13850,7 @@ const server = http.createServer(async (req, res) => {               sendJSON(res, { success: true, output: JSON.stringify({ ok: true, cancelled: true }) });               return;             }-            await startAnnotation(session, sessionId, _callerThread);+            await startAnnotation(session, sessionId, _callerThread, { tool: args.tool === 'snip' ? 'snip' : 'pen' });             // the human is about to draw — the window must be in front of them. A jump-list click IS             // the user asking, so this raise is user-sanctioned by construction.             session._foreground = true; session._userActiveAt = Date.now();