Docs

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):

  1. --user-data-dir=<tmp> — own window-state + single-instance lock.
  2. ATOM_HOME=<tmp>, seeded from ~/.tranquil (theme + packages preserved, empty session) — so the launch never pollutes or overwrites your real ~/.tranquil window layout.
  3. Opens the dedicated tranquil-test-suite repo, not tranquil-client, so the app never operates on real source. It also sets TRANQUIL_VERIFY=1, which activates a gated filter in src/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 plain yarn start still 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-store header 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.log prints directly.
  • Renderer / webview console.log also appears, as [pid:...:INFO:CONSOLE(line)] "..." lines with a source: 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:

ChangePicked 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 buildsWindow 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, setDevToolsWebContents can’t embed it (Chromium OOPIF limit); open native DevTools on the guest’s webContents instead, or just use CDP as above.
  • Puppeteer — connect with puppeteer.connect({ browserURL: 'http://localhost:9222' }) (not browserWSEndpoint). Note browser.newPage() throws Target.createTarget: Not supported on 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:

  • getComputedStyle lies about pseudo-elements. getComputedStyle(el, '::after').content reports the value you set even when the pseudo-element never paints — e.g. <input> elements don’t render ::before/::after in 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 changes scrollWidth/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. Use getBoundingClientRect / getComputedStyle via evaluate for 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.onDidAddNotification handler 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 connect needs global fetch; 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.