Agent Deck

A read-only VS Code extension that renders live Claude Code session topology — subagent trees,
in-flight tool calls, token and cost totals — by observing Claude Code's exhaust. It never wraps,
proxies, launches, or configures Claude Code.
Claude Code compatibility — anchor 2.1.234, accepted window 2.0.229 to 2.2.239
(major exact, minor +/-1, patch +/-5). See Claude Code version window.
This badge is text, not a remote image: a project whose selling point is zero egress should not
make its own README phone home to a badge service to render.

Read-only by design
Agent Deck observes. It never acts.
- It reads Claude Code's session JSONL and listens for hook events. That is all.
- It never wraps, proxies, launches or configures Claude Code.
- It never writes to
~/.claude, to Claude Code settings, or to your session
files. Installing the hooks is a manual paste block you control, below.
- Zero network egress. The only socket it opens is an HTTP listener bound to
127.0.0.1, which is how the hooks reach it. Non-loopback requests are
dropped.
- No telemetry, no analytics, no CDN. Every asset the panel renders is local,
enforced by a strict Content-Security-Policy.
All state lives in memory and is discarded when the window closes.
Features
The deck - every live session at a glance, breathing while it works.

Agent topology - the main agent as a nucleus, tool calls as chronological
dots, and subagents joined by filaments to the exact tool_use block that
spawned them. That join is a primary key, not a guess.

Tool call inspector - open any node for its payload, truncated with an
explicit marker and with thinking blocks dropped at the parse boundary.

What it is
Claude Code leaves two kinds of exhaust behind, and Agent Deck reads both without touching either:
| Tap |
Source |
What it answers |
| Hooks |
a hook snippet you paste into your settings POSTs to a loopback HTTP listener |
what is running right now |
| JSONL |
~/.claude/projects/<slug>/..., read from local disk |
what happened |
The split is deliberate. The hook contract is documented and stable with thin payloads; the session
files are undocumented and rich. Keeping them on separate failure paths means a Claude Code schema
change degrades the panel instead of killing it.
Everything the extension knows lives in memory in the extension host and is discarded when the
window closes. There is no database, no cache file, and nothing is ever written back to Claude Code.
Requirements
- VS Code
^1.75.0
- Node
>=20 on your PATH — the hook block below is a node -e one-liner, so your Node is
what runs it
- Claude Code at a version inside the accepted window (see below)
Install
Install from the VS Code Marketplace - open the Extensions view and search for
Agent Deck, or run:
code --install-extension nvitlam.agent-deck
Then install the hook block below. It is not optional: without it Agent Deck can still read
session transcripts, but nothing tells it what is running right now, so liveness is inferred from
file mtime alone.
Install the hook (one manual paste)
Content and the tree render from the session files alone. The hook tap is what makes liveness
live — which agent is running right now, which tool call is in flight.
Agent Deck never installs this for you and never writes either settings file. Read-only means
read-only, including your configuration. You paste it; you own it.
Paste the "hooks" key below into one of:
- your project's own
.claude/settings.local.json — what this repository does, and the choice that
keeps ~/.claude untouched entirely; or
- your user-level
~/.claude/settings.json, if you would rather have it everywhere.
Both files are JSON objects. Merge the "hooks" key into whatever is already there rather than
replacing the file.
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "node -e \"let b='';process.stdin.setEncoding('utf8');process.stdin.on('error',()=>process.exit(0));process.stdin.on('data',c=>b+=c);process.stdin.on('end',()=>{const r=require('http').request({host:'127.0.0.1',port:47821,path:'/event',method:'POST',headers:{'content-type':'application/json','content-length':Buffer.byteLength(b),connection:'close'}},s=>{s.resume();s.on('end',()=>process.exit(0))});r.on('error',()=>process.exit(0));r.setTimeout(1000,()=>{r.destroy();process.exit(0)});r.end(b)});setTimeout(()=>process.exit(0),2000)\"",
"timeout": 5
}
]
}
],
"PreToolUse": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "node -e \"let b='';process.stdin.setEncoding('utf8');process.stdin.on('error',()=>process.exit(0));process.stdin.on('data',c=>b+=c);process.stdin.on('end',()=>{const r=require('http').request({host:'127.0.0.1',port:47821,path:'/event',method:'POST',headers:{'content-type':'application/json','content-length':Buffer.byteLength(b),connection:'close'}},s=>{s.resume();s.on('end',()=>process.exit(0))});r.on('error',()=>process.exit(0));r.setTimeout(1000,()=>{r.destroy();process.exit(0)});r.end(b)});setTimeout(()=>process.exit(0),2000)\"",
"timeout": 5
}
]
}
],
"PostToolUse": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "node -e \"let b='';process.stdin.setEncoding('utf8');process.stdin.on('error',()=>process.exit(0));process.stdin.on('data',c=>b+=c);process.stdin.on('end',()=>{const r=require('http').request({host:'127.0.0.1',port:47821,path:'/event',method:'POST',headers:{'content-type':'application/json','content-length':Buffer.byteLength(b),connection:'close'}},s=>{s.resume();s.on('end',()=>process.exit(0))});r.on('error',()=>process.exit(0));r.setTimeout(1000,()=>{r.destroy();process.exit(0)});r.end(b)});setTimeout(()=>process.exit(0),2000)\"",
"timeout": 5
}
]
}
],
"SubagentStart": [
{
"hooks": [
{
"type": "command",
"command": "node -e \"let b='';process.stdin.setEncoding('utf8');process.stdin.on('error',()=>process.exit(0));process.stdin.on('data',c=>b+=c);process.stdin.on('end',()=>{const r=require('http').request({host:'127.0.0.1',port:47821,path:'/event',method:'POST',headers:{'content-type':'application/json','content-length':Buffer.byteLength(b),connection:'close'}},s=>{s.resume();s.on('end',()=>process.exit(0))});r.on('error',()=>process.exit(0));r.setTimeout(1000,()=>{r.destroy();process.exit(0)});r.end(b)});setTimeout(()=>process.exit(0),2000)\"",
"timeout": 5
}
]
}
],
"SubagentStop": [
{
"hooks": [
{
"type": "command",
"command": "node -e \"let b='';process.stdin.setEncoding('utf8');process.stdin.on('error',()=>process.exit(0));process.stdin.on('data',c=>b+=c);process.stdin.on('end',()=>{const r=require('http').request({host:'127.0.0.1',port:47821,path:'/event',method:'POST',headers:{'content-type':'application/json','content-length':Buffer.byteLength(b),connection:'close'}},s=>{s.resume();s.on('end',()=>process.exit(0))});r.on('error',()=>process.exit(0));r.setTimeout(1000,()=>{r.destroy();process.exit(0)});r.end(b)});setTimeout(()=>process.exit(0),2000)\"",
"timeout": 5
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "node -e \"let b='';process.stdin.setEncoding('utf8');process.stdin.on('error',()=>process.exit(0));process.stdin.on('data',c=>b+=c);process.stdin.on('end',()=>{const r=require('http').request({host:'127.0.0.1',port:47821,path:'/event',method:'POST',headers:{'content-type':'application/json','content-length':Buffer.byteLength(b),connection:'close'}},s=>{s.resume();s.on('end',()=>process.exit(0))});r.on('error',()=>process.exit(0));r.setTimeout(1000,()=>{r.destroy();process.exit(0)});r.end(b)});setTimeout(()=>process.exit(0),2000)\"",
"timeout": 5
}
]
}
]
}
}
Notes on that block, each of them measured rather than assumed:
- It is
node -e, not curl, and that is not a style choice. This command runs inside your real
Claude Code session on every tool call. Against a closed loopback port — which is what it finds
whenever Agent Deck is not running — node takes ECONNREFUSED and exits 0 in well under a
fifth of a second, while curl.exe burns its full connect timeout (measured between 1.1 s and
2.1 s, exiting non-zero) and stalls your session that long every single time. Simplifying it to
curl costs you roughly an order of magnitude, forever, on the common path. SECURITY.md §5
carries the numbers.
- The port must match
agentDeck.port. The block names 47821 literally, which is that
setting's default. If you change one, change the other: Agent Deck reports a port collision as an
error and never silently picks a different port, because the block you pasted has no way of being
told.
- No Claude Code restart is needed. Hook settings are re-read per invocation — registering a new
event and seeing it arrive without a restart was measured on
2.1.234.
- The POST is unconditional. With nothing listening it is refused and nothing happens. A quiet
listener is not evidence that hooks stopped firing.
- Six events are registered:
SessionStart, PreToolUse, PostToolUse, SubagentStart,
SubagentStop, Stop. Registering fewer still works — liveness degrades rather than fails, and
falls back to transcript modification times with a banner — but the panel gets blunter.
Open the panel
Press Ctrl+Shift+P (Cmd+Shift+P on
macOS), type Agent Deck, and run Agent Deck: Open Session Deck.
That is the only entry point. Agent Deck contributes no sidebar icon, no status-bar item and no
activity-bar view - one command, one panel.
Open the folder your Claude Code session runs in first. The extension matches the open workspace
against your ~/.claude/projects directories; if that folder has no Claude Code sessions it says so
and does nothing else.
Usage
Three levels, and Escape walks back up out of any of them.
Deck - every Claude Code session on the machine, one blob each. The chip row filters by
liveness: all, live, idle, ended, refused. Colour is the same channel
everywhere - green live, yellow idle, grey ended, red refused - and the legend at the bottom of the
panel restates it. Drag to pan, wheel to zoom, click a session to go inside it.
Topology - the session interior. The main agent is the nucleus, each tool call is a dot placed
in chronological order around it, and a subagent hangs off a filament drawn from the exact tool-call
dot that spawned it. Click any node to open it in the inspector. The Deck breadcrumb returns to
the deck, and Reset view re-centres pan and zoom without changing anything else.
Inspector - the detail pane for whatever is selected. Per agent it lists status
(running, done, error), tokens as in / out, duration and spawn depth, with the tool
payload beneath it. Show details / Hide details collapses the payload, Close dismisses
the pane.
Settings
| Setting |
What it does |
agentDeck.port |
The loopback port the hook listener binds on 127.0.0.1. Must match the port in the block you pasted. |
agentDeck.livenessThresholdMs |
How long a session may go quiet before it stops counting as live. Set it too low and one long tool call makes a healthy session flap. |
agentDeck.previewBytes |
Ceiling on tool-payload bytes kept per node for previews. Nothing is ever sent off the machine either way. |
Privacy
SECURITY.md ships inside the VSIX alongside this file and carries the enforcement detail and the
measurements. The short version:
- Zero egress. The only socket is the inbound loopback hook listener, bound to the literal
127.0.0.1. There is no outbound HTTP client compiled into the shipped bundle at all, and a test
fails if that changes. POSTs from a non-loopback origin are dropped on the strength of the
socket's own remote address — proxy headers are attacker-controlled strings and are never
consulted for that decision.
- No persistence, no telemetry. All state is in memory in the extension host and is discarded
when the window closes. No database, no cache file, no analytics of any kind.
- Redaction at the parse boundary. Thinking blocks are dropped, and the
signature field is
dropped with them — Claude Code writes thinking blocks with an empty text string and the bytes in
signature, so dropping only the visible text would be doing nothing. Tool payloads are truncated
with a marker, including the large ones Claude Code offloads to tool-results/*.txt.
- The webview has no filesystem and no network access, enforced by a strict Content Security
Policy. It receives snapshot and diff messages and sends back UI intents; that is the whole
channel.
- Stated plainly: any process running as you can POST to the loopback port and inject fabricated
liveness events. There is no authentication, because the only way to add one would be a shared
secret carried in the snippet you pasted. What an injected event buys is a wrong picture in a
read-only panel: nothing is executed, nothing is written, nothing leaves the machine.
Claude Code version window
src/parser/fingerprint.ts is the authority for this, and the numbers have exactly one home there:
PINNED_CC_VERSION is the anchor and VERSION_WINDOW is the allowance.
- Anchor
2.1.234 — the version the committed fixtures were captured from, and the only version
whose behaviour is pinned byte-for-byte.
- Accepted window
2.0.229 to 2.2.239 — major exact, minor +/-1, patch +/-5. It is a box, not
a lexicographic range: a version has to be inside the tolerance on each component separately.
- Out-of-window, malformed and unreadable versions are refused: the session renders
unsupported,
never a partial tree.
- A transcript whose version changes partway through — Claude Code updating itself under a live
session — is accepted while every version in it stays in-window, and refused as
versionChangedMidFile once the drift leaves it.
Why a window instead of a single pinned version, and what it costs. A single pin meant the
product went dark the moment Claude Code updated itself: every session written that day rendered
unsupported. That was the refusal rule behaving exactly as designed, and a product that only works
between Claude Code releases. The window buys tolerance for the patch bumps that actually happen, and
it costs certainty: an in-window version nobody captured is a version nobody verified, so schema
drift inside the window can surface as a wrong tree rather than an honest refusal.
What it does not do
- No writes of any kind. Not to
~/.claude, not to your Claude Code settings, not to session
files. Zero write capability is the trust anchor, not a default that could be configured away.
- No launching, wrapping or proxying Claude Code. It observes what is already there.
- No historical replay and no persistence. Close the window and the state is gone.
- No telemetry, no analytics, no network egress.
- No cost dashboards. Totals are rendered where they belong on the tree, and that is all.
Development
Build and side-load from a checkout:
npm ci
npm run build
npm run package
code --install-extension dist/agent-deck.vsix
Licence
See LICENSE.
Status
Released on the VS Code Marketplace as nvitlam.agent-deck. The parser, the grafter, the liveness
engine and the renderer are covered by an automated suite that runs against transcripts captured
from real Claude Code sessions.