Agent Debugging
The dev build exposes the Chromium DevTools Protocol (CDP) on localhost:9222, so you can
inspect and script a running instance from the outside — list every renderer/webview, evaluate
JavaScript inside a specific tab, and read what actually loaded — without touching the UI.
Enabled in tranquil-client/src/main-process/start.js:
app.commandLine.appendSwitch('remote-debugging-port', '9222'); 0. Launch a dev instance from an agent shell
Everything below assumes an instance is running. To start one:
cd tranquil-client && source ~/.nvm/nvm.sh && nvm use && yarn start The ELECTRON_RUN_AS_NODE gotcha. An agent/IDE shell (e.g. the VSCode
extension host Claude runs inside) exports ELECTRON_RUN_AS_NODE=1. When that
leaks into the environment yarn start spawns Electron with, the Electron binary
runs as a plain Node interpreter instead of a GUI app — it then rejects the
Chromium flags and dies in ~0.3s:
.../Electron: bad option: --no-sandbox
.../Electron: bad option: --enable-logging The tell is the bad option: prefix — that’s Node’s unknown-flag format, not
Chromium’s, so the binary is running as Node. Confirm it directly:
# reports Node's version (e.g. v20.x) when running as Node,
# Electron's own version (e.g. v30.x) when running as Electron:
electron --version
env -u ELECTRON_RUN_AS_NODE electron --version scripts/dev.js strips the variable from the child env before spawning, so yarn start works from any shell. (Unrelated: src/buffered-node-process.js sets ELECTRON_RUN_AS_NODE=1 on its own child env to run helper Node
processes — that’s correct.)
Launch a fully isolated verify instance — yarn verify. One command
(scripts/verify.js) does the whole thing and holds the app open:
yarn verify # background this It isolates everything and prints status ([verify] roots: [...], CDP URL):
--user-data-dir=<tmp>— own window-state + single-instance lock.ATOM_HOME=<tmp>, seeded from~/.tranquil(theme + packages preserved, empty session) — so the launch never pollutes or overwrites your real~/.tranquilwindow layout.- Opens the dedicated
tranquil-test-suiterepo, nottranquil-client, so the app never operates on real source. It also setsTRANQUIL_VERIFY=1, which activates a gated filter insrc/main-process/atom-application.js(openPaths) that drops the resource-path (tranquil-client) root before any window opens — zero flash. (Electron forces the app dir.as its first positional and Atom otherwise opens it as a project too; the filter is inert without the env var, so a plainyarn startstill opens tranquil-client.)
Override the project with yarn verify <path>. Confirm from the printed status
that roots is exactly ['.../tranquil-test-suite'], or via CDP:
pgrep -f 'user-data-dir=.*tranquil-verify' # a live process, not gone in 0.3s
curl -s http://localhost:9222/json/version # CDP responds → GUI is up
# via CDP: atom.project.getPaths() === ['.../tranquil-test-suite'] Teardown is clean. scripts/dev.js forwards SIGINT/SIGTERM to the
Electron child, so stopping the launcher stops the whole app — no orphaned
GUI, no pkill. Just stop the backgrounded task (the harness sends SIGTERM;
verified to reap every child and release :9222). Before that fix the wrapper
exited but left Electron orphaned, which is what forced the broad pkill. If a
fallback kill is ever needed, match the unique dir
(pkill -f 'user-data-dir=.*tranquil-verify'), never the shared binary path, so a
real instance is never caught in the blast radius.
Use Node ≥18 for any puppeteer CDP client (§7).
1. Confirm an instance is live
curl -s http://localhost:9222/json/version Returns the Electron/Chrome version and a User-Agent containing Tranquil/<version>. No response
means no dev instance is running (or it’s on a different port).
2. List targets
curl -s http://localhost:9222/json | python3 -m json.tool Each entry has type (page / webview), title, url, and a webSocketDebuggerUrl. Browser
tabs are webviews; the editor window is a page. Filter to what you want, e.g. every browser
webview:
curl -s http://localhost:9222/json
| python3 -c "import sys,json;[print(t['type'],'|',t.get('title','')[:24],'|',t['url'][:90]) for t in json.load(sys.stdin) if t['type']=='webview']" Multiple webviews, same URL? A HAR-replay tab and a normal tab can share a URL. Distinguish them by evaluating something only one would have — e.g. archive-served responses carry a
cache-control: no-storeheader a plain disk load doesn’t (see the replay verification below).
3. Evaluate JS inside a specific target
The DevTools UI can’t always reach a <webview> guest, but CDP can. Open the target’s webSocketDebuggerUrl and send Runtime.evaluate. This self-contained helper needs no npm deps
(raw WebSocket over the stdlib) and supports await via awaitPromise:
import json, socket, base64, os, urllib.request, urllib.parse
def eval_in(url_substr, expr):
targets = json.load(urllib.request.urlopen('http://localhost:9222/json', timeout=3))
t = next(t for t in targets if url_substr in t.get('url', ''))
u = urllib.parse.urlparse(t['webSocketDebuggerUrl'])
s = socket.create_connection((u.hostname, u.port))
key = base64.b64encode(os.urandom(16)).decode()
s.sendall(f"GET {u.path} HTTP/1.1\r\nHost:{u.hostname}:{u.port}\r\n"
f"Upgrade:websocket\r\nConnection:Upgrade\r\n"
f"Sec-WebSocket-Key:{key}\r\nSec-WebSocket-Version:13\r\n\r\n".encode())
s.recv(4096) # skip handshake response
def send(o):
d = json.dumps(o).encode(); h = bytearray([0x81]); n = len(d)
h += bytes([0x80 | 126, (n >> 8) & 0xff, n & 0xff]) if n >= 126 else bytes([0x80 | n])
m = os.urandom(4); h += m
s.sendall(bytes(h) + bytes(b ^ m[i % 4] for i, b in enumerate(d)))
def recv():
b = s.recv(2); ln = b[1] & 0x7f
if ln == 126: ln = int.from_bytes(s.recv(2), 'big')
elif ln == 127: ln = int.from_bytes(s.recv(8), 'big')
data = b''
while len(data) < ln: data += s.recv(ln - len(data))
return json.loads(data)
send({"id": 1, "method": "Runtime.evaluate",
"params": {"expression": expr, "awaitPromise": True, "returnByValue": True}})
while True:
r = recv()
if r.get('id') == 1:
s.close()
return r.get('result', {}).get('result', {}).get('value')
# Examples used to diagnose real bugs:
print(eval_in('main-view.html', "getComputedStyle(document.body).fontFamily"))
print(eval_in('main-view.html', "performance.getEntriesByType('resource').map(e=>e.name)"))
# Async: prove a replay tab is served from the HAR archive, not disk —
# archive responses carry cache-control: no-store.
print(eval_in('main-view.html', """(async()=>{
const r = await fetch('mockup.css', {cache:'no-store'});
return JSON.stringify({status:r.status, cacheControl:r.headers.get('cache-control')});
})()""")) This is exactly how the original “unstyled page” bug was found (document.styleSheets / fontFamily showed the CSS never loaded) and how HAR replay was verified (the no-store fingerprint proved archive serving).
4. Read logs
The terminal running yarn start is the primary log surface:
- Main-process
console.logprints directly. - Renderer / webview
console.logalso appears, as[pid:...:INFO:CONSOLE(line)] "..."lines with asource:path.
So temporary console.log('[tag] ...') breadcrumbs in either process show up in one place — the
fastest way to pinpoint where an async flow stalls (e.g. the [har] stage logs that located a Network.enable hang).
5. What a reload actually reloads
This trips everyone up:
| Change | Picked up by |
|---|---|
Theme LESS (tranquil-business-*/styles/*.less) | Stylesheet reload (file-watch, or reloadStylesheets() via CDP) — CSS only; see the caveat |
Renderer code — owned packages linked link:../ (e.g. tranquil-browser, tranquil-automations), plus atom.config.setSchema and any DOM a package builds | Window reload (Cmd-R / Window: Reload) |
Main-process code — src/main-process/* (e.g. cz-init.js, har.js, IPC handlers, protocol setup) | Full quit + relaunch only |
Stylesheet-reload caveat (this cost hours once). A theme stylesheet reload — the file-watcher, or atom.packages.getLoadedPackage(name).reloadStylesheets() over CDP — updates the CSSOM and computed
styles, but it does not re-run any JavaScript, and it does not reliably repaint existing ::before/::after pseudo-elements (the CSSOM changes; the paint layer doesn’t reflow). So after a style-only change a stylesheet reload is fine; after anything JS/schema/DOM-factory — or a
change to a drawn pseudo-element — do a full window reload. When driving remotely, remember reloadStylesheets() ≠ package reload: your new JS is not running. Before diagnosing “my fix
doesn’t work,” confirm the fix is even loaded — inspect the live DOM/state (e.g. is the new element
present? does atom.config.getSchema(key) show the new shape?). More than once the “bug” was simply
old code still running because only CSS had reloaded.
Pulsar runs a single main process and require()-caches its modules, so a window reload never
re-runs main-process code. Worse, yarn start while an instance is already running hands off to
the existing process — so it can look like you restarted when you didn’t (tell-tale: the renderer
pid in the logs is unchanged, and stale main-process code still runs). For a guaranteed-clean
main-process reload:
pkill -f 'tranquil-client/node_modules/electron'
cd tranquil-client && source ~/.nvm/nvm.sh && nvm use && yarn start 6. Alternatives
- DevTools UI — for a normal renderer/page, the standard DevTools work. For a
<webview>guest,setDevToolsWebContentscan’t embed it (Chromium OOPIF limit); open native DevTools on the guest’swebContentsinstead, or just use CDP as above. - Puppeteer — connect with
puppeteer.connect({ browserURL: 'http://localhost:9222' })(notbrowserWSEndpoint). Notebrowser.newPage()throwsTarget.createTarget: Not supportedon Electron — attach to existing targets instead of creating new ones.
7. Pitfalls that cost real time
Learned the hard way while building the settings UI:
getComputedStylelies about pseudo-elements.getComputedStyle(el, '::after').contentreports the value you set even when the pseudo-element never paints — e.g.<input>elements don’t render::before/::afterin Chromium, so a drawn checkmark silently no-ops while looking correct in computed styles. Trust rendered pixels, not computed styles, for pseudo-elements — and if you can’t screenshot (below), infer from geometry (a real mark changesscrollWidth/box sizes).Page.captureScreenshot/page.screenshot()can hang forever on a webview-heavy renderer (the compositor never settles). Don’t build your loop around screenshots there. UsegetBoundingClientRect/getComputedStyleviaevaluatefor layout facts, and lean on the human in the loop for “what does it look like.”- Measure, don’t eyeball. Layout disputes (“the label’s too high”, “the value’s indented”) resolve
in one shot by reading
getBoundingClientRect()of the elements and comparing edges/centers. Guessing pixels burns rounds. - Find the target by DOM, not window title. The page title follows the active tab, so a filter
like
/^Settings/misses the settings renderer when another tab is focused. Match on a DOM selector (document.querySelector('.settings-view …')) instead. - “Why is X always N?” → grep for a global interceptor. A toast-duration setting looked ignored
because a stray
atom.notifications.onDidAddNotificationhandler force-dismissed every info toast at a hard-coded 1s, overriding every per-toast timer. When a value seems to have no effect, search the owned packages for global observers/handlers on that subsystem. - Node version for the CDP client. Puppeteer’s
connectneeds globalfetch; the system Node 16 doesn’t have it — run the driver under Node ≥18 (nvm use→ 20.x).
Safety
CDP evaluation runs arbitrary JS in the live app with full privileges. Keep it to read-only
inspection when diagnosing (getComputedStyle, getBoundingClientRect, performance.getEntries, fetch of a resource); don’t mutate app state through it unless that’s explicitly the goal.
Live-patching as a deliberate stopgap is legitimate when a full reload is expensive and a handler
reads state live — e.g. delete atom.config.getSchema(key).minimum to stop an input clamp, or
clone-and-rewire a button — so the user can test now. But it is a throwaway: the source edit is
what persists across reloads, so always land the code change too, and don’t confuse “works after my
live patch” with “the committed code works.”
Don’t hammer the live instance. Repeated reloadStylesheets() in a tight loop, plus hung
screenshot attempts and pkill cleanups, once wedged a renderer into a blank white-out. Single,
purposeful evaluate calls are safe; storms of them are not.