Launch (or attach to) the Silo dev app and drive it for runtime verification through the dev automation RPC bridge — exec commands, eval DOM, capture screenshots, and create/activate/delete workspaces, terminals, editors. This is the repo's GUI evidence-capture handle for the `verify` skill. Use when verifying a change by running the real app and observing it. Always works inside a throwaway sandbox workspace, never the user's real workspaces.
Installs into .claude/skills of the current project.
Are you the author of Verifier Gui?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/silo-code-verifier-gui)
---
name: verifier-gui
description: Launch (or attach to) the Silo dev app and drive it for runtime verification through the dev automation RPC bridge — exec commands, eval DOM, capture screenshots, and create/activate/delete workspaces, terminals, editors. This is the repo's GUI evidence-capture handle for the `verify` skill. Use when verifying a change by running the real app and observing it. Always works inside a throwaway sandbox workspace, never the user's real workspaces.
tools: Bash, Read
---
# Silo GUI Verifier
The handle the `verify` skill looks for: how to get the running Silo app under
control and capture evidence from it. Silo is a Tauri desktop app; its surface is
pixels + a dev-only RPC bridge. This skill drives that bridge.
**It does not judge.** It launches, drives, captures. The verdict is `verify`'s.
## Golden rule 1: verify in a sandbox workspace, never the user's
The app may be the user's live session with real workspaces and terminals. **Do
all verification in a workspace you create from a temp dir, and delete it when
done.** Never `openTerminal`/`deleteWorkspace`/`openFile` against an existing
workspace — you'd pollute or destroy real state. Create → activate → verify →
delete. This also makes destructive paths (workspace delete, session kill) safe
to exercise.
## Golden rule 2: one turn, not one op per turn
The wall-clock cost here is **agent turns**, not the RPC bridge — each bridge call
is ~milliseconds on localhost, but every separate `Bash` tool call is a full model
round-trip (seconds). So **issue a whole drive + capture sequence as a single
`Bash` call.** The `silo()` helper is just `curl`; bash variables (`WS_ID`,
`WS_DIR`) persist _within_ one invocation, so create → activate → drive →
screenshot → decode all belong in **one** block (see §2). A 6-step flow then costs
2 turns, not 7.
Only split into a separate turn when you genuinely must:
- **The final `Read /tmp/silo.png`** — `Read` is its own tool, so capture-in-one-turn
then read-in-the-next is the floor (2 turns).
- **Branching on an observed result** — if the next op depends on what you _saw_
(a count, a tab list, a pass/fail), end the block, read the output, then decide.
A fixed setup sequence has no such dependency — never split it.
Echo any state you'll need next turn (e.g. `echo "WS_ID=$WS_ID"`) — bash vars die
at the end of the Bash call.
## 1. Get the app up (attach or launch)
The bridge listens on `127.0.0.1:7878` (dev builds only — `app:dev` is built
`--features automation`). Define the request helper first — the contract is
strict: header `X-Silo-Automation: 1` **and** a loopback `Host`, `POST /`, body
`{"op", "args"}`.
```bash
silo(){ curl -s -m30 -X POST http://127.0.0.1:7878/ \
-H 'X-Silo-Automation: 1' -H 'Content-Type: application/json' \
--data "$1"; }
```
Attach if it's already running, else launch:
```bash
if [ "$(silo '{"op":"ping"}')" = '{"ok":true,"result":"pong"}' ]; then
echo "attached to running dev app"
else
pnpm dev >/tmp/silo-appdev.log 2>&1 & # first run compiles Rust — slow
for i in $(seq 1 120); do # poll up to ~4 min
sleep 2
[ "$(silo '{"op":"ping"}' 2>/dev/null)" = '{"ok":true,"result":"pong"}' ] && break
done
fi
```
`app:dev` runs under the isolated **"Silo Dev"** identity (separate app data), so
launching never touches the user's real Silo install — but if you _attached_ to
an already-running instance, the sandbox rule above still applies.
**If you attached (ping succeeded on the first try), verify it's actually serving
your code before doing anything else.** A `pong` only proves _some_ dev app is
listening on 7878 — it can just as easily be a stale instance from a different
checkout, a different branch, or a session someone else started hours ago, with
none of the changes you're here to verify. Confirm the process identity, not just
liveness:
```bash
ps aux | grep "target/debug/silo\b" | grep -v grep
# expect the binary path to start with YOUR working directory, e.g.:
# /path/to/your/repo/apps/desktop/src-tauri/target/debug/silo
# a path pointing anywhere else means you're about to verify the wrong code —
# stop and either ask the user to close it or launch your own (a port conflict
# will make that obvious rather than silently reusing the wrong instance)
```
This check is cheap (one `ps`) against the cost of it going wrong: driving and
debugging against the wrong checkout produces confident-looking PASS/FAIL results
for code you didn't touch, and any oddity you hit sends you chasing a phantom bug
in your own change instead of noticing the mismatch.
## 2. Drive & capture — one block, one turn
Per golden rule 2, do the whole sandbox setup, drive steps, and screenshot in a
**single `Bash` call**. Bash variables persist within the invocation, so the
workspace id flows from one op to the next with no agent round-trip. End the block
with the screenshot + decode; the only follow-up turn is `Read /tmp/silo.png`.
```bash
# ── ONE Bash call = ONE turn ──────────────────────────────────────────────
WS_DIR=$(mktemp -d /tmp/silo-verify.XXXXXX)
WS_ID=$(silo "{\"op\":\"openWorkspace\",\"args\":{\"folder\":\"$WS_DIR\",\"name\":\"verify-sandbox\"}}" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["result"]["id"])')
silo "{\"op\":\"activateWorkspace\",\"args\":{\"id\":\"$WS_ID\"}}"
# ── drive steps (add as many as the check needs) ──
silo '{"op":"exec","args":{"command":"core.newTerminal"}}'
# silo "{\"op\":\"openFile\",\"args\":{\"path\":\"$WS_DIR/README.md\"}}"
# silo '{"op":"eval","args":{"expr":"document.querySelectorAll(\".xterm\").length"}}'
# ── capture as the LAST step in the same block ──
silo '{"op":"screenshot"}' > /tmp/shot.json
python3 -c "import json,base64;d=json.load(open('/tmp/shot.json'));r=d['result'];open('/tmp/silo.png','wb').write(base64.b64decode(r['png_base64']));print('shot',r['width'],r['height'])"
echo "WS_ID=$WS_ID" # surface state needed for next-turn cleanup
```
Next turn: `Read /tmp/silo.png`. The capture can be **slow (a few seconds),
especially the first call** — keep the timeout ≥30s and retry once if it returns
empty. No OS permission setup is required.
A single-folder workspace also means `core.newTerminal` won't pop the folder
picker (which automation can't click) — it resolves the lone folder directly.
**Reading evidence without a screenshot** — for structure/counts, an inline `eval`
in the same block is cheaper than a picture: `document.querySelectorAll('.xterm').length`
(terminal count), `[...document.querySelectorAll('.dv-tab')].map(t=>t.textContent)`
(open tabs), `document.body.innerText.includes('Session ended')` (spawn failure).
**WebGL caveat:** terminals render to a **canvas** (WebGL addon) — `.xterm` has
**no DOM text**, so to read what a shell _printed_ you need a screenshot, not
`textContent`.
### Op catalog
`exec` runs a registered command (the real `ctx` path); `eval` runs JS in the
webview global scope (note: app modules like `store` are **not** in scope — use
the dedicated ops for state). The full, authoritative list is the `switch (op)`
in `apps/desktop/src/automation/bridge.ts` — **read it when in doubt**; this table
mirrors it. `eval` is the escape hatch, but for Monaco state prefer the dedicated
editor ops below — `monaco` is not a page global, so `eval` can't reach it.
**Core / liveness**
| Op | Args | Returns / use |
| ------------ | --------- | ------------------------------------------------------ |
| `ping` | — | `pong` — liveness |
| `exec` | `command` | run a command id (menu/keybinding dispatch) → `{ran}` |
| `eval` | `expr` | evaluate JS in the page; awaits a returned promise |
| `screenshot` | — | host-side window capture → `{png_base64,width,height}` |
**Workspaces / panels** (sandbox only — never point at real workspaces)
| Op | Args | Returns / use |
| ------------------- | ---------------------------- | ---------------------------------------------------------------------------------------------------------- |
| `listWorkspaces` | — | `{active, workspaces[{id,name,folder}]}` |
| `openWorkspace` | `folder,name` | create + activate (use a temp dir) → `{id}` |
| `activateWorkspace` | `id` | switch active → `{active}` |
| `addFolder` | `id,folder` | attach a folder, bypassing the native OS picker ("Add Folder…" in Workspace Properties) → `{extraFolders}` |
| `closeWorkspace` | `id` | close (sets `closedAt`, doesn't remove) → `{closed,active}` |
| `deleteWorkspace` | `id` | reap terminals + remove → `{deleted,active}` |
| `splitActivePanel` | `position?` (l/r/top/bottom) | split center group → `{groups}` |
| `activatePanel` | `panelId` | focus a dock panel → `{activated}` |
| `showSidePanel` | `id` | expand slot + activate its tab → `{shown,slot}` |
**Editors / terminals**
| Op | Args | Returns / use |
| --------------- | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `openFile` | `path` | open an editor tab → `{editorId,panelId}` |
| `openDiff` | `path,providerId,args?,title?,preview?` | open a diff tab via a content provider → `{diffId,panelId}` |
| `listEditors` | `workspaceId?` | `{previewEditorId, editors[{id,filePath,title,isPreview,mode,providerId}]}` |
| `openTerminal` | `cwd?` | `ctx.terminals.create` → `{terminalId,panelId}` |
| `sendText` | `terminalId,text,addNewline?` | write to a terminal's PTY (force-spawns if never mounted) → `{sent:true}` |
| `listTerminals` | `workspaceId?` | `{terminals[{id,title,sessionId,kind}]}` |
| `listPanels` | `workspaceId?` | `{panels[{id,kindId,workspaceId,state,createdAt,lastActiveAt}]}` — recorded dock panels (RFC 0041); assert a panel reopens from its record across a restart |
**Monaco introspection / drive** (authoritative — straight from Monaco's registry; `uri` matches by substring of the model URI)
| Op | Args | Returns / use |
| ---------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| `monacoEditors` | — | live editors `[{uri,hasTextFocus,valueLength,valueTail}]` |
| `editorsDetail` | — | per-editor focus + container-visibility ground truth (focus-handoff debugging) |
| `focusLog` | `clear?` | Monaco focus-event timeline (`{clear:true}` resets it) |
| `editorContent` | `uri` | read a model's current text → `{uri,value}` \| `null` |
| `editorOptions` | `uri` | resolved Monaco config (font/tab/wrap/minimap/readOnly/…) for that editor |
| `setEditorValue` | `uri,value` | `model.setValue` → fires `onChange`, i.e. the **real edit→dirty→save/backup path**, no OS focus needed → `{uri,valueLength}` \| `null` |
**Output logs** (read what the app or extensions have logged)
| Op | Args | Returns / use |
| ------------ | ------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| `outputLogs` | `channel?,level?,search?,limit?` (all opt.) | `{channel,displayName,totalCount,entries[{timestamp,level,message,data?}],channels[{key,displayName}]}` |
- `channel` defaults to the first registered channel. Discover all channels via the `channels` field in any response.
- `level`: `"debug"` / `"info"` / `"warn"` / `"error"` / `"all"` (default `"all"`).
- `search`: case-insensitive substring on `message`.
- `limit`: most-recent N entries (default 200; ring buffer holds 5 000 per channel).
```bash
# All recent logs (first channel, up to 200)
silo '{"op":"outputLogs"}'
# Errors only from notifications channel
silo '{"op":"outputLogs","args":{"channel":"silo:notifications","level":"error","limit":50}}'
```
**Theme / process / introspection**
| Op | Args | Returns / use |
| --------------- | -------------------- | ---------------------------------------------------------------- |
| `themeState` | — | `{activeId, presets[], customThemes[]}` |
| `setTheme` | `id` | switch active theme → `{activeId}` |
| `processExec` | `command,args?,cwd?` | one-shot `ctx.process.exec` → `{stdout,stderr,code}` |
| `contextKeys` | — | host context-keys snapshot (`activeEditorId`/`activeViewerId`/…) |
| `activeElement` | — | describe what holds DOM focus |
To **dirty an editor without keyboard focus** (e.g. verifying save / dirty
indicator / hot-exit backups): `openFile`, then `setEditorValue` with the file's
basename as `uri` and new `value` — this drives the real `onChange`. Read it back
with `editorContent`, or screenshot for the dirty dot.
The typed client `src/automation/client.ts` (`SiloAutomation`) wraps these if you
prefer TS over curl.
## 3. Clean up (always)
```bash
silo "{\"op\":\"deleteWorkspace\",\"args\":{\"id\":\"$WS_ID\"}}" # reaps its terminals/panels
rm -rf "$WS_DIR"
```
If you launched the app yourself, you may leave it running (next verify attaches)
or kill the backgrounded `pnpm dev` — but never kill an instance you
attached to (it's the user's).
## Gotchas (learned the hard way)
- **Header quoting**: `-H 'X-Silo-Automation: 1'` — an unquoted/space-mangled
header gets a `403 {"error":"forbidden"}`.
- **`exec` vs `openTerminal`**: `exec("core.newTerminal")` drives the real
`ctx.terminals.create` path; the `openTerminal` op is a lower-level test setup
that calls record APIs directly — prefer `exec` when verifying the `ctx` path.
- **Focus-sensitive checks** (asserting a `<textarea>` is `document.activeElement`)
only pass while the window is frontmost; an agent session can't hold focus, so
gate them on `SiloAutomation.foreground()` and SKIP otherwise — don't FAIL.
- **Code freshness**: confirm the running app is the code under test, not just that
_a_ dev app answers `ping` — see the identity check in §1. An attached instance
can be a different checkout entirely (wrong repo clone, wrong branch), not just
stale HMR.
- **`eval` has a hard 5-second reply timeout independent of curl's `-m`.** The
bridge's Rust side (`REPLY_TIMEOUT` in `automation.rs`) gives up waiting on the
webview after 5s and returns `{"ok":false,"error":"timed out waiting for
webview reply"}` — but **the JS keeps running in the page regardless**, since
nothing on the page side knows the host stopped listening. A driver script that
does `await sleep(15000)` internally will report a timeout error to you while
still fully executing moments later — actions you think failed actually land,
which is deeply confusing to debug from the outside. Never put multi-second
waits inside an `eval` payload. Instead: fire one fast action (a `.click()`
returns essentially instantly), `sleep` in **bash** between calls, then a
second fast `eval` to read the result:
```bash
silo '{"op":"eval","args":{"expr":"document.querySelector(\"button\").click(); \"clicked\""}}'
sleep 2 # bash sleep, not JS sleep — the RPC call itself stays fast
silo '{"op":"eval","args":{"expr":"document.querySelector(\".result\").textContent"}}'
```
This also composes with golden rule 2 — each fire/sleep/read trio is still
cheap `curl` calls inside one `Bash` block, just no longer racing the 5s limit.
- **`listWorkspaces` returns every real workspace on the machine, closed ones
included** — when picking one to "switch away to" (e.g. to try forcing a
remount), don't just grab the first non-sandbox id from the list. A closed
workspace has `closedAt` set; `activateWorkspace` on one **reopens it**
(clears `closedAt`), silently undoing a deliberate close the user made,
possibly weeks ago. Check `closedAt` first, or better, target a workspace
you already know is open. If you do this by mistake, `closeWorkspace` (not
`deleteWorkspace`) restores it.
- **Bash variables never survive across separate `Bash` tool calls** — each is
a fresh shell. If a workspace/terminal id from one block is needed in a
later one, hardcode the literal id string (from what you echoed) at the top
of the new block; don't reference `$WS_ID` and assume it's still set. An
unset variable silently expands to `""`, so e.g. `activateWorkspace` with an
empty id fails quietly rather than erroring loudly — easy to misread as "it
worked" when it didn't do what you intended.
- **Switching workspaces away and back does not remount a backgrounded
terminal panel** — Silo's entire premise is keeping background work alive,
so a terminal that's merely not-visible stays mounted and never re-triggers
its attach/reattach effect. There's no bridge op to force just one tab to
remount (no terminal-close-tab op, only whole-workspace `closeWorkspace`/
`deleteWorkspace`). To test something that only happens on that a real
panel mount (e.g. reattach-after-daemon-death), a full app restart is
required — which the golden-rule-1 sandbox discipline doesn't license doing
on an attached (not self-launched) instance. Report the limitation rather
than reaching for `eval`-driven DOM clicks on the user's live session to
work around it.
- **In a dev build (`target/debug/silo`), the PTY session daemon is not a
separately-named process** — it's the same `silo` binary self-exec'd with
`--session-host <id> <folder> <cols> <rows> -- <shell>`, not a distinct
`pty-host` binary (that may differ in a release build). To kill just one
session's daemon for a death-transition test, match on `--session-host
silo-<first 8 hex chars of the terminal's sessionId>` and its folder —
never a bare `pkill -f pty-host`-style pattern, which would hit every real
session-host process on the machine, not just the sandbox one.