Let an agent investigate the heap dump in a window somebody is watching - #2966
Merged
Conversation
Shark Explorer's windows now answer MCP, so an agent reads the dump that is already open — the same tree, the same verdicts, the same notes — rather than a copy of its own, and `show` puts what it is looking at on the person's screen. The reason it is a server rather than a set of shark-cli commands is that a server can say no, and refusing is the whole mechanism: it works with any client and nothing here ever calls a model. - Every call takes a `reason`, enforced in AgentTool.call rather than only asked for in the schema, and it lands in the run's log beside the reads it caused. An investigation becomes something a person can follow afterwards instead of a conclusion they have to trust. - A verdict needs a reason another reader can check, and one that contradicts a verdict already recorded is refused with the list of what it disagrees with. - `conclude` is refused until the heap dump itself agrees that one reference is at fault, and the refusal says which of the three ways that fails it is. An agent that has narrowed a chain to three unexplained steps cannot report a root cause, however confident it is. The method — the LeakCanary method, as prose for a model — is handed over twice, in the handshake and again with open_heap_dumps, because some clients drop the handshake's instructions and a method nobody read is a method nobody followed. Two parts to the transport: a run publishes a loopback port and a token under ~/.shark-explorer/agents, and `--mcp-stdio` is a mode of the same app binary that pipes stdio to it, since an MCP client can be configured with a command and not with a port that changes every run. harness/start-harness.sh is how this actually gets tested: it opens a window and prints the command that throws an agent with no knowledge of this repository at it, prompted with nothing but "find the root cause". The documentation quotes that run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The dock reads the file name of the bundle a process was launched from, so every window of the packaged app is a tile called "Shark Explorer" and two harness runs are indistinguishable on screen. So clone the app image, name the copy after the title, and set the two plist keys that name the menu bar — measured, all three names come from different places, which is now in the guide. The copy also has to live outside `build/compose`: another Compose task deletes that app image, and a window whose bundle went away under it dies the way a window launched from source does. `cp -c` clones, so 240 MB costs 80 ms and no disk. And two notes on what this surface should be: what MCP costs a client here (3,300 tokens of tool definitions and 1,240 of method, measured rather than guessed, against the 17,600 of GitHub's server), why a CLI and a skill are adapters over one registry rather than second implementations of the rules, and how to score whether an agent can actually solve a leak without a model doing the scoring. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An agent reported NOT_LEAKING about an object the window called Expected, which is one tool with two vocabularies: the person watching and the agent working cannot check each other if the words change at the edge of the process. So the enum is STUCK, EXPECTED and UNKNOWN now, and those are what the window shows, what the files keep and what the agent surface takes and answers with. Shark's LEAKING and NOT_LEAKING stop at the door. LeakFingerprint is the one place that maps to LeakTraceObject.LeakingStatus, because a fingerprint has to be the string LeakCanary computes. A verdict file written before this has Shark's words in it, and its rows are skipped with a line in the log saying which. That is deliberate rather than a compatibility shim: this app is an alpha and the file is three columns of text. Its header now says "verdict" too, since that is the word the window uses. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An investigation an agent ran and one a person ran are the same investigation: the same tree, the same verdicts, the same notes. So what an agent did belongs in the window, and in words — the *Agent logs* screen is one row per agent that has connected, and opening one is every call it made with the sentence it gave for making it. A row leads where the call went, so reading what an agent did and going to look at it are one move. A call about another heap dump is drawn and leads nowhere: an address is an address of one dump. The rows are a file, `~/.shark-explorer/agents/sessions/*.jsonl`, one per connection and the newest hundred kept. One artefact with two readers, which is why the reading half sits beside the writing half: this screen, and the eval in notes/agent-eval.md, which now has a session record to score a run from rather than prose to scrape. The description of a call is worked out before the call is answered, so a refused call still records what it was asking about — the refusals are the half of a session worth reading afterwards, and one nobody can follow up on is a dead end on the screen. Which is also why the refusal for a contradicted verdict is prose now rather than a JSON array of the verdicts it disagrees with: a refusal is the one answer on this surface a person reads, and that one was three verdicts and their reasons as raw protocol on the screen that exists to not show it.
A row of a session read `Described 0x12d368b8`, which is the address the agent wrote and not what this window calls that object anywhere else. Now it reads `Described MainActivity 0x12d368b8`, from the same titleOf the tabs are named by, so that a row and the tab clicking it opens are recognisably one object. Resolved in the window rather than recorded in the session file: an address is what the agent said, and what it stands for is a read of the heap dump this window has open — the read that names a tab. Which leaves a call about another dump as the address it was, since naming it would mean reading a dump nobody here has open. An agent can also name an address this dump has no object at, and that call is a row too, so the address is asked about before it is named: titleOf throws on an object the graph hasn't got.
A chain that names a faulty reference is an investigation that is over, and until now the only way to see that was to find the marked step: a real chain is tens of steps, and the pane is scrolled to the last of them. So the answer is also said where the eye starts, as a two line `Leak solved` section above the list, and handed to an agent as `faultyReference` at the top of the chain rather than left to be found by scanning the steps for isFaulty. Both read the same spelling, `PathReference.leakLabel()`, which the leaks screen and the note `conclude` writes now use too — four surfaces naming one leak four ways is four leaks to whoever is grepping. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The agent surface answered questions about a heap dump somebody else had opened. Which meant an agent that needed the treemap, or a second dump, or a dump off a device had one answer available: ask your human to click something. So the five tools that were missing: - `dominator_tree`, the treemap without the pixels. Bounded in both directions, because the answer is a tree and every node is a read. - `read_notes`, and `take_note` gaining `replace`. A surface that could only append leaves a wrong conclusion in the notes of the object it is wrong about, under a correction — and the next reader finds both. - `open_heap_dump` and `list_devices`/`dump_heap`, which are the two buttons above the map. The two that make a window answer once the dump can be *read*, not once the window exists: everything else here is a read, so a window id handed over mid-index refuses every call made with it. That needs a failed open to be visible outside the composition, hence `ExplorerWindow.openProblem` — and without it a file that was never a heap dump is a call that never comes back. `AgentHeapDumps` stops being a `fun interface`; the tests get a fake of it rather than a lambda. The place vocabulary moves to `AgentPlace.kt`, both directions in one file, which is also what keeps `AgentTools` under detekt's class size. Re-measured what the surface costs a client: 16 tools, 18,880 characters of definitions, up from 13,116 for eleven.
An agent could reach the tools only through a window somebody had already opened, so a machine with no screen — a build server, a heap dump at the end of an ssh session, anything driving an agent unattended — had no way in, and even on a desktop the first answer was "ask your human to launch Shark Explorer". Two halves. `--mcp-stdio` with nothing running now starts a window, on the heap dump its own command line named, and leaves it open for whoever comes back to it. And `--no-ui` serves the tools from that process with no window anywhere. One code path with one call swapped rather than a headless mode beside the windowed one: `AgentHeapDump.show` gained a return, and it is the only thing that differs. Which is the point — the notes and the verdicts were never on the screen, so a heap dump investigated with no window opens in one later with the verdicts, the reasons and the conclusion already on it. `show` answers with why it showed nothing rather than saying it did, because an agent telling its human where to look is worse than saying nothing when there is nowhere to look. Two things found by driving it for real rather than by reading it: A window opened for an agent publishes the run before its heap dump is readable, so the agent's first move is to open the path it was pointed at — and that used to make a second window and a second index of the same file. Opening a path some window already has now hands back that window, which is the opposite of what the button does, and on purpose: a person clicking `Open heap dump…` twice is comparing two readings, an agent naming a path is naming a heap dump. The headless side joins an open already in flight for the same reason, which is also what lets a dump named on the command line be indexed in the background instead of holding up the client's `initialize`. And every bridge session ended with a `SocketException` trace on stderr — the answers thread reading a socket this end had just closed — which is where an MCP client collects a server's log, so a clean exit read as a crash. It now knows the close was its own, and the test asserts stderr stays quiet.
`show` and `conclude` put a place on screen, which is right once and wrong five times: raising a window over what somebody was doing is a hand-over that happens now or not at all. A `shark://` link is the same hand-over they can take with them, and the answer an agent writes lands somewhere this app can't reach — a chat window, a pull request comment, a bug report — so a link in that sentence is the difference between an answer to take on trust and one to go and look at. So `AgentHeapDump.show` answers with a `ShownPlace`: the link, or why there was nowhere. One answer rather than two calls, because a link names a window and so whether there is a link and whether anything was shown are the same fact — a `--no-ui` run handing out a link would be handing out an address nothing answers to.
How much of the window a note is worth is the reading of it against the reading of the heap dump, and that changes with the note: a paragraph about which of two caches this is wants four lines, and an argument about a root cause with the chain quoted in it wants the half of the window it takes to be read while looking at the chain. A number in the code can only be right for one of them, so it is an edge, dragged the way the edges between the panes already are. The height is per window, beside the pane widths and for the same reason: it is how somebody has set their desk up for the job in hand, and a note that shrank as they clicked through tabs would be the window rearranging itself. And it is never more than a share of the room the tab has, because the edge is the only way back — a note dragged tall on a big screen and then a window made small would otherwise put its own edge past the bottom of the screen, and nothing but resizing the window would bring it back.
Found by asking a real dump the question this surface's own docs suggest: `find_objects` with `query`, which is what the window calls its search box, matched nothing in particular and answered with the thirty biggest objects out of 86,056 — a list of `ResourcesImpl` and bitmaps in answer to a question about `BuildConfig`. Nothing said no, because nothing read the argument. Which is the failure this surface is built to make impossible: a wrong answer that reads like an answer. So an argument no tool property matches is refused, naming it and naming the ones the tool takes, and the schema says `additionalProperties: false` so a client that validates can catch it before the call rather than after.
The method stopped where the tools stop: it says isolating the reference is not the root cause, and then leaves the reading of the code — the only place a root cause can come from — as one clause in one bullet. An agent that reads the framework on `main` and the app not at all writes "nothing clears this in onDestroy" about a class the app doesn't ship, which is confident, checkable-looking and wrong. So the method now says which copy to read and how the dump itself says which that is, and each claim in it was checked against a real dump first: `android.os.Build$VERSION` carries `SDK_INT`, `RELEASE`, `CODENAME` and `SECURITY_PATCH`; the app's `ApplicationInfo` carries its process, its `dataDir`, the APK it was installed from, `minSdkVersion`, a `targetSdkVersion=` in `seInfo` and `FLAG_DEBUGGABLE` in `flags`. And `BuildConfig` is not in a dump at all — its constants compile into their call sites, so the class never loads — which is why the app's own version is something to ask for rather than look up.
A session recorded what was asked and never what came back, which is fine for every tool that answers with data and wrong for the one that answers with a conclusion. So the row that ends a session read "Concluded about MainActivity" and the reference — the whole point of the call — was only in the note it wrote. `outcomeOfTool` reads it off `conclude`'s answer, the screen draws it after an arrow, and the eval now has something to mark against an answer key. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An agent whose first call asked what was open, 2.6 seconds into a run started on a heap dump, was told nothing was open and was not told the path it had been started on. So it guessed one, opened a different heap dump, and investigated that instead — confidently, and right through to a conclusion about a dump nobody had asked it about. A dump that is still indexing cannot be in `openHeapDumps`: a window id is a promise that every tool given it answers, and one that isn't readable can't keep it. But leaving the path out of the answer altogether is worse than either, so `open_heap_dumps` now names what this run was pointed at, and says to call `open_heap_dump` with it rather than to go looking. Both the run with a window and the run without one had the hole, since a window exists for as long as its dump takes to index. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every change to a tool description, a refusal or the method is a prompt change, and a prompt change is not something anyone can review by reading it. JProfiler measured theirs and found one model going from 38/55 scenarios to 55/55 on the same tools with better descriptions — a change nobody would have predicted from the diff. So: heap dumps whose answer key is known before the tools are asked anything, a client per run, and a score that is a string comparison and a count over the session file the server wrote while the agent worked. No model marks anything, because a model judging an answer is a second unverified opinion. `shark-explorer-eval` is the dumps and the scoring — its own module because writing a scenario needs `shark-hprof-test` in a main source set, and that is not a dependency anything shipped should have. `harness/eval/run-eval.sh` is the process handling between them. Four things about it are only there because a run was handed its own answer, and every one of those was found by running it: the dump's file name, the client's working directory, the notes of the run before, and the notes of the eval before that one. The fourth cost a day and is written up in notes/agent-eval.md, because it read exactly like a model getting a leak wrong. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The *Agent logs* screen drew a bare `0x12d368b8` for most rows, and the rows it did name were the only ones that led anywhere. Both came from the same mistake: the window resolved the addresses itself, so it could only speak for calls about the dump it happened to have open, and a session spans every dump that was open while it ran. So the name is recorded as the call is made, by the run that has that dump open — `AgentTarget.about`, one small read per call — and the screen shows what is written down. A row reads the same in any window now, and a row about another heap dump opens that dump instead of doing nothing. It only leads nowhere when the file has been deleted, which it says by naming it. Sessions written before this have no name against their calls, so `subject` falls back to the address the agent typed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`list_leaks` is the only tool whose place comes from which tool it is rather than from an argument, so recording a name for it made its row read "Listed the leaks Leaks" — the verb already says the whole of it.
A window is a heap dump, so the *Agent logs* screen of one is the agents that read *that* dump: `AgentSession.heapDumpPaths` is what decides, and the sessions about another dump are listed under `Other heap dumps` and opened in a window of theirs. There is no window that isn't a heap dump for them to be read in, and read against the wrong one they are rows of addresses that mean other objects. Which is also why naming an object goes back to the window, undoing the `about` recorded on every call: the reader has the heap dump the session is about, so it can resolve an address itself, and a session written before the recording was added no longer reads differently from one written after. The one row left as an address is a call that went on to another dump, which says which file and opens it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`--agent <tool> name=value …` makes one call and prints the answer, and `--agent-help` says what the calls are with no window, no heap dump and no Gradle. For an agent whose client speaks no MCP, and for one that has a shell and hasn't been configured with anything. It is argument translation and nothing else: it builds a `tools/call` on the loopback socket the run already publishes, so a refusal it prints was thrown by the handler that would have refused an MCP client, and the help is generated from the same registry `tools/list` answers from. Which also means a call from a shell is not a slower call — measured at 160 to 180 ms, a JVM starting and a socket, against a heap dump that was parsed and indexed once in the window somebody is watching. What a process per call would otherwise lose is the session: a connection is what gathers an MCP investigation, and a command per question would be a row per call on the *Agent logs* screen. So the handshake carries an optional session name after the token, `AgentSessionFile.continuing` appends to the file that already has it, and a shell's calls default to `cli<its pid>`. Checked at both ends, because it becomes part of a file name — refused before anything is called at this end, served with a session of its own and a line in the log at the other. Exit codes carry the rest: 0 with JSON on stdout, 2 with the refusal on stderr, 1 when there was nothing to answer it.
The *Agent logs* screen had no tool behind it, which broke the rule that every screen has an agent equivalent — and it is one of the more useful screens to have: an investigation somebody already ran is either the answer or the half of the dump not worth doing again. `agent_log` lists the sessions that read this dump, newest first, with what each concluded and how many calls were refused; with `session=`, every call of one in order with the reason the agent gave. Refused by name for a session that read another dump, because an address is an address of one dump: reading it here would be rows meaning other objects. Which is the rule the screen groups by, so both readers of a session file agree. `dump_heap` said the garbage was collected "where the device is new enough", which was vague and wrong: it is collected either way — `am dumpheap -g` from API 27, and `JdwpGc` running the same collection in the process below that. Detekt's LargeClass now excludes tests, the way TooManyFunctions already does, and allows 700: a test class is as long as the story it walks, and AgentTools is a registry whose length is tool descriptions rather than behaviour. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The screen listed this window's sessions and then "Other heap dumps", which buried the one thing a reader needs to know about a session: which dump its addresses belong to. Now it is "Agent log" over one group per heap dump, headed with the file name — numbered when two dumps share one — and this window's group first, saying so. A row of a session was clickable end to end, so clicking the word "Described" navigated. Only the object leads anywhere now, unless the call named none, where the verb is the whole sentence and is the link itself. Which also fixes "Listed the leaks Leaks": the window named a place derived from the tool rather than from an argument, so the leaks screen's title landed after a verb that already said it. It only names what the call itself said it was about, which is one fewer heap dump read as well. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The tools were reachable and undiscoverable: MCP needed a client someone had configured, and `--agent` needed somebody to say it was there. So the skill — `.claude/skills/shark-explorer/SKILL.md`, which is the directory every client that reads the standard looks in, so it is the project skill here and the directory a user copies into `~/.claude/skills`. It frames the three cases an agent is actually in — something already open, a file to open, a device to dump — rather than assuming a window is up, and it points at `--agent-help` and at the method the tools hand over rather than repeating either. The description is not leak-only, and neither is the surface any more: taking a dump used to say "call list_leaks to see what the dump says about itself", which points a dump somebody took because the app was using a gigabyte away from the question. It names dominator_tree too. And `find_objects` now says that with no className it is the biggest objects in the dump, which is what "what's big in my heap" needs and which nothing said. Checked by doing it: `--agent dump_heap` took a 146 MB dump of com.squareup off an API 29 emulator in 90 seconds and opened it, and `dominator_tree` and `find_objects` on that window found the class loader's 42 MB of classes and, below it, a Coil image cache holding a 4 MB bitmap. The eval note has the plan for scoring that as a scenario, including why the class loader answer is the failure a "what's big" dump has to provoke. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A row of the Agent logs screen is a sentence with one link in it, and the link was the whole sentence whenever the call named nothing: "Listed the leaks" and "Read the agent log" were underlined verbs. A verb now stops where the thing starts — "Listed the" and then *leaks* — which needs the words for a screen to live beside the tool names rather than being taken from the tab titles, since a tab says `Leaks` and a sentence says leaks. Two calls had no link at all, and both are things a person does in this window: dominator_tree with no object is the tree from its root, and find_objects with no className is the object list unfiltered, which also stops that row reading as "Searched for in com.squareup.hprof". AgentScreen is the words and the place as one value, read by AgentTools.placeOrNull and by the screen, so a screen cannot be reachable and unnamed or named and unreachable. Sessions already on disk have no link for those two, so the place is worked out from the tool when the file says none. The call that asks which heap dumps are open answers with a list, so its row unfolds into them: each dump a window away, the one this window has open marked and leading nowhere. Which needs the answer recorded, like a conclusion — the run that had them open has ended by the time anybody reads it. And every session row leads somewhere now. One about another heap dump opens in a window of that dump as before; the ones with no such window to be had — a dump that has been deleted, or a client that read none — are read here rather than being dead rows, since an address whose file has gone resolves to nothing in any window there is, and the verbs, the reasons and the refusals are still what the agent said. The group of a dump that has gone says so. "Described" is gone: it read as the agent having written a description rather than having asked what an object is. Every read now starts with Read, Looked, Listed, Searched or Asked, and every write with Recorded, Took, Wrote, Showed, Concluded, Opened or Dumped.
A link was `shark://<window id>/<place>`, which made it die with the window
— and that is most links a day later, and most links in an agent's session
log, since a session outlives the run that wrote it. Every place a link can
name belongs to the heap dump rather than to whatever is showing it, so the
dump is now the identity:
shark://<file name>/<place>?<place>[&dump=<path>][&window=<id>]
The file name is the authority because it is what a person reads and types
and what every agent answer already carries; `dump=` is the normalized path,
since two `com.squareup.hprof` off two devices are two investigations and a
path is the only thing that can open a dump nothing has open. `window=` is
honoured while that window exists and ignored once it doesn't, rather than
turning the link into an error — which keeps the case the window id was for,
the same dump open twice being two readings of it compared.
Links already written into notes and session files still resolve: the last
step of `ExplorerWindows.windowFor` matches the authority against a window
id. A run claims a link only for a window it already has, never for a file
it could open, or every run would claim every link.
Two things this unlocks, and the reason to reverse it rather than live with
it. A `--no-ui` run answers `show` with a link now — no window and the file
all the same — instead of saying there was nothing to hand back. And every
*Agent logs* row about another heap dump has a link to copy, where before
there was nothing to send.
On the agent surface the argument is `heapDump`, taking a file name, with a
window id only where a name cannot answer.
A copied link was 220 characters, four fifths of them a percent encoded
path that says nothing to whoever reads it:
shark://leak.hprof/object?id=0x12d368b8&dump=%2FUsers%2F…&window=zvphq4r3
The path was in there because a link has to outlive the run it was copied
from, and nothing else remembered where the file was. So something else
does: HeapDumpPaths writes down the path of every heap dump that opens,
under the id of the window it opened in, the newest 200 kept, and
following a link is a lookup. The same link is now
shark://leak.hprof/object?id=0x12d368b8&window=zvphq4r3
What it costs is that a link works for as long as this machine remembers
the file rather than for as long as the file exists — and a link that has
been forgotten says so, and can still be given &dump=<path> by hand,
which is also the answer for a dump this machine has never opened.
Recorded under the window id rather than the dump, because that answers
both questions with one file per open: a window id resolves to the dump
it was showing, and a file name to the newest record with that name. So
`shark://<window id>/<place>` — the shortest a link can be, and what this
app used to write — opens the right heap dump again after every window of
it has gone, instead of finding nothing.
A run with no window records its dumps too, which is what makes the link
`show` hands back from a --no-ui run resolve for the next reader.
A link was carrying the window it was copied from, then the heap dump's whole path, and both were answers to a question that hardly ever comes up: which of the heap dumps called that. Heap dump file names are as good as unique — this app names every dump it pulls off a device after the process, its pid and a random number, and LeakCanary names its own after the time of the dump — so a link that says `shark://bug-4821.hprof/leaks` names one file on any machine that has it, and the long form was paying for the rare case in every link. So the name is the whole of what a link says, and the rare cases are asked about rather than encoded: - A window has that heap dump open, which is nearly always. That window is where the link goes. - None has, but this machine has had it open. It opens, from where it was last seen. - Two heap dumps of that name, open or on record. The reader picks, by path. - Nothing here knows the name — a link from another machine, a dump deleted or opened long ago. The reader is asked for the file, since silence can't be told from the app having failed to start. One dialog puts both questions, because both answers are a path. `&dump=` is still there for a link that wants to say where the file is, and it does the picking for the reader when it does.
A window keeps the file it was given even when opening it failed, so it went on claiming every link about that name and every one of them landed on the error message. The file is usually openable from where this machine last saw it — the case that found this was a run started with a relative path, which the OS resolves against `/` for an app it launched — so a window whose heap dump failed to open is no longer a window that has it, for links and for what this run claims from its peers. And say plainly there is no file there, with the absolute path, rather than letting the parser report the path as it was given: `/shark/shark-android/…/leak_asynctask_o.hprof` is what says the working directory wasn't the checkout, where the relative path it was typed as says nothing at all.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Shark Explorer's windows now answer MCP, so an agent investigates the heap
dump that is already open — the same tree, the same verdicts, the same notes — rather than a copy of its own,
and
showputs what it is looking at on the screen of the person watching.{ "mcpServers": { "shark-explorer": { "command": "/Applications/Shark Explorer.app/Contents/MacOS/Shark Explorer", "args": ["--mcp-stdio"] } } }Why a server rather than shark-cli commands
Because a server can say no, and refusing is the whole mechanism — it works with any client, and nothing here
ever calls a model:
reason, enforced inAgentTool.callrather than only asked for in the schema, andit lands in the run's log beside the reads it caused. That makes an investigation something a person can
follow afterwards instead of a conclusion they have to trust. (Traceability, not a quality gate — asking a
model to explain itself doesn't make it right.)
refused with the list of what it disagrees with, the same way the window asks a person.
concludeis refused until the heap dump itself agrees one reference is at fault, and the refusal sayswhich of the three ways that fails it is:
An agent that has narrowed a chain to three unexplained steps cannot report a root cause, however confident
it is, and what it gets instead is the three objects to go and read.
The method — the LeakCanary method, as prose for a
model — is handed over twice, in the handshake and again with
open_heap_dumps, because some clients drop thehandshake's instructions and a method nobody read is a method nobody followed.
Seventeen tools
open_heap_dumps,open_heap_dump,list_leaks,agent_log,chain_from_gc_root,describe_object,ways_held,find_objects,dominator_tree,set_verdict,clear_verdict,read_notes,take_note,show,conclude,list_devices,dump_heap— each one a read the window already does or a button above themap, so the agent surface and the UI are the same internals with different interfaces. Parity is the rule in
both directions: a capability added to the window is a tool, and a tool is something the window can do. Which
is what
agent_logis: the Agent logs screen had no tool behind it, and what has already been tried on aheap dump is either the answer or the half of it not worth doing again.
And it is not only about leaks.
list_leaksis the dump's own answer about what shouldn't be there;dominator_treeandfind_objectsare where the memory went, which is a different question and the one askedmost after it. Checked end to end from a shell: a 146 MB dump of a real app taken off an API 29 emulator with
dump_heap, and the answer — 42 MB of loaded classes, and under that a Coil image cache holding a 4 MB bitmap— out of
dominator_treeandfind_objectson the window it opened.What it did, in the window
The window is where an agent's work is read, not only in a terminal it happened to be started from: the
Agent logs screen is one row per agent that has connected, and opening one is every call it made, in
words, with the sentence it gave for making it.
A row leads where the call went — click the object and the window opens it — so reading what an agent did
and going to look at it are one move. The object, not the verb: a row is a sentence about a thing, and the
thing is what a reader wants to look at, so only that part is a link. Refused calls are rows too, in red, and
they are the half of a session worth reading afterwards, which is why what a call was about is worked out
before the call is answered rather than after.
Grouped by heap dump, this window's first and saying so, because an address is an address of one dump: a
session read against the wrong one is a screen of rows naming other objects. A group that isn't this window's
opens that dump instead of pretending to be about it, which is also what
agent_logrefuses across.The rows are a file,
~/.shark-explorer/agents/sessions/*.jsonl, one per connection and the newest hundredkept. One artefact with two readers: this screen, and the eval planned in
notes/agent-eval.md, which now hasa session record to score a run from rather than prose to scrape.
It is also why the refusal for a contradicted verdict is prose now instead of a JSON array of the verdicts it
disagrees with — a refusal is the one answer on this surface a person reads, and that one landed on the screen
that exists to not show the protocol.
The transport, in two parts
A run publishes a loopback port and a token under
~/.shark-explorer/agents/<pid>.agent, and--mcp-stdioisa mode of the same app binary that pipes stdio to it. Two parts because an MCP client can be configured with a
command and not with a port that changes every run. Deliberately not the socket
shark://links use: a linkis one line answered in a millisecond, this is a session held open for as long as an investigation takes.
Three ways in, one registry
MCP is the shape a client discovers, and it needs a client somebody configured. So the same tools are also
a command —
--agent <tool> name=value …, with--agent-helpgenerated from the registry — which reaches thesame window over the same socket, in 160 to 180 ms, and exits 2 with the refusal on stderr when the surface
said no. It is argument translation and nothing else: a refusal it prints was thrown by the handler that would
have refused an MCP client, so there is no second place where the rules live.
And a skill,
.claude/skills/shark-explorer/SKILL.md, because a tool nobody knows about is a tool nobody calls. It framesthe three cases an agent is in — something already open, a file to open, a device to dump — and points at
--agent-helpand at the method the tools hand over rather than repeating either, which is what keeps it fromgoing stale.
.claude/skills/is where every client that reads the standard looks, so it is the project skillhere and the directory a user copies into
~/.claude/skills.The costs are measured in
notes/agent-surface.md: 21,123 characters oftools/listdefinitions paid everyturn over MCP against 14,594 characters of
--agent-helppaid only when read, which is 3.5% of a 200 kcontext window for the whole surface either way.
How it was tested
57 unit tests and 5 UI tests, plus
harness/start-harness.sh, which is how the thing this module is for actually gets tested:it opens a window on a real dump and prints the command that throws an agent with no knowledge of this
repository at it, prompted with nothing but "find the root cause".
That run followed the method unprompted on
leak_asynctask_o.hprof—list_leaks, the chain, fivedescribe_objectcalls,ways_held, three defendedEXPECTEDverdicts,find_objectsto check theactivity wasn't a singleton — and concluded
MainActivity$2.this$0, with how the reference came to be set, howto reproduce it, and four things it had not checked. The documentation quotes that session's log and the note
it left behind.
And a number for the whole thing
Every change to a tool description, a refusal or the method is a prompt change, and a prompt change is not
something anyone can review by reading it — JProfiler measured
theirs
and found one model going from 38/55 scenarios to 55/55 on the same tools with better descriptions. So
shark-explorer-evalgives this surface heap dumps whose answer key is known before the tools are askedanything,
harness/eval/run-eval.shthrows a client at one per run, and the score is a string comparison and acount over the session file the server wrote while the agent worked. No model marks anything: a model
judging an answer is a second unverified opinion.
Six for six is a ceiling and therefore not much of a baseline; the row worth pointing at is sonnet on
two-apart, refused once for passing an argumentset_verdictdoesn't take, recovering on the next call, andfinishing in 9 calls against opus's 15. Harder scenarios are listed in
notes/agent-eval.md, along with thefour ways a run turned out to be handed its own answer — all four found by running it rather than by reading
it, and the last of them is why
open_heap_dumpsnow names the heap dump a run was pointed at while it isstill indexing. An agent that asks what is open, is told nothing is, and is not told where its own dump is has
one move left, which is to guess a path. Two of them did.
A link says which heap dump, and nothing else
shark://<window id>/<place>made every link die with the window it was copied from, which is most links aday later — and most links in an agent's session log, since a session outlives the run that wrote it. Every
place a link can name belongs to the heap dump rather than to whatever is showing it, so the dump is the
identity. Two versions of that went too far before this one: the first carried the file's path beside the
name, 220 characters with four fifths of them a percent encoded path, and the second carried the window as a
refinement of the name. Both were paying, in every link, for a case that hardly ever comes up. Heap dump file
names are as good as unique — this app names every dump it pulls off a device after the process, its pid and a
random number, and LeakCanary names its own after the time of the dump — so the name is the whole of it:
Four ways that goes, and the first is nearly always the one:
looked at.
HeapDumpPathswrites down where every heap dump that opensis, in
~/.shark-explorer/heap-dump-paths, the newest 200 kept — so the file opens, from where it was lastseen.
those apart, so the reader is asked which, by path.
too long ago to still be on record. The reader is asked for the file. Asked, rather than left with silence:
silence is the one answer that can't be told from the app having failed to start, and a link is usually
followed from somewhere that can see neither way.
A window whose heap dump failed to open counts as not having it, in all four: the file it was given may
well be openable from where this machine last saw it — the case that found this was a run started with a
relative path, which the OS resolves against
/for an app it launched — and landing a link on the windowthat says so is the one outcome that helps nobody.
One dialog puts both questions, because both answers are a path: rows for the paths this machine knows of,
and a file picker under them for a heap dump that is at none of them. The window that asks comes to the front,
and keeps the sentence in its middle after the dialog is gone, so a question dismissed leaves the reason on
screen rather than an empty window with nothing to explain it.
Measured on a packaged build, every path a link can take:
— the window that had it open; the same link after that run had ended, looked up and opened; one name in two
windows, asked about and then answered by picking the emulator's copy; and a name nothing here has ever seen,
which asks for the file.
&dump=/path/to/<name>is still there for a link that wants to say where the fileis, and it answers the question for the reader when it does.
Two things this unlocked: a
--no-uirun answersshowwith a link now, having no window and the file allthe same, instead of saying there was nothing to hand back — and every Agent logs row about another heap
dump has a link to copy, where before there was nothing to send. On the agent surface the argument stays
heapDump, a file name, withwindowonly where a name cannot answer.🤖 Generated with Claude Code