)]}'
{
  "log": [
    {
      "commit": "66aab8d2ab695c4e1c013ed1bd5c3884939c1e2f",
      "tree": "963988f405f37aa093ccc1e802459c2b46423f78",
      "parents": [
        "9cb24da5bc77de6416e51d28163e242ac4d75bd9",
        "e6728017a4245296a549a470d682f7373229a16b"
      ],
      "author": {
        "name": "py - Pierre Yves Ricau",
        "email": "pyricau@users.noreply.github.com",
        "time": "Mon Aug 31 14:51:15 2026"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Mon Aug 31 14:51:15 2026"
      },
      "message": "Merge pull request #2969 from square/debug-follow-along\n\nLog the full agent traffic, and which way in each message came"
    },
    {
      "commit": "e6728017a4245296a549a470d682f7373229a16b",
      "tree": "963988f405f37aa093ccc1e802459c2b46423f78",
      "parents": [
        "b74ce5b5feb3a7a93da378f60f836755ddf89cce"
      ],
      "author": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Mon Aug 31 14:22:15 2026"
      },
      "committer": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Mon Aug 31 14:39:51 2026"
      },
      "message": "Log every message an agent sends, and which way in it came\n\nAn agent\u0027s session kept the calls that reached a tool. So a refused\n`conclude` was a row with no answer under it, and a call to a tool that\ndoes not exist, a method this build has never heard of and a line that\nwas not JSON were not rows at all — which is exactly the traffic somebody\nopens that screen to look at, since the question it gets opened for is\nusually why nothing happened.\n\nNow every line goes down: `tool` is null for the ones that reached no\ntool and `method` says what arrived instead, `output` is what went back\nwhatever that was, and `error` is separate from `refused` because they\nare opposite claims — a refusal is the method working and an error is\nthis app failing. `AgentSession.toolCalls` is the subset that got as far\nas a tool, which is what the eval counts and what the screen\u0027s `n\ncall(s)` says, so a command line\u0027s handshake per call doesn\u0027t read as\ntwice the work.\n\nAnd a line records whether it came in over MCP or from `--agent` at a\nshell. Told to `McpSession` at construction rather than worked out: past\nthe handshake the two are the same protocol on the same socket, which is\nthe point of them, so the door is the only place that knows. It travels\nas the last word of the handshake line, and a connection that says\nnothing is MCP.\n\nLeaving `output` null for a refusal was a deliberate call — the refusal\nwas already in `refused`, so writing it twice looked like the same\nparagraph on disk twice. From outside it looked like a call the app\nanswered and did not log.\n\nCo-Authored-By: Claude Opus 5 \u003cnoreply@anthropic.com\u003e\n"
    },
    {
      "commit": "b74ce5b5feb3a7a93da378f60f836755ddf89cce",
      "tree": "16647ab1c9ada7cb7c2a50428aceb6a929d10e5d",
      "parents": [
        "9081eb84ef92190483e82ae71cc4c07015ca95c3"
      ],
      "author": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Sat Aug 29 12:56:45 2026"
      },
      "committer": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Mon Aug 31 14:39:13 2026"
      },
      "message": "Open a call from under its row, and keep the tool\u0027s name in what it sent\n\nTwo things about the fold that lands a call\u0027s exchange on the Agent logs\nscreen.\n\nThe verb was the fold, which put two pressable pieces in one sentence doing\ndifferent things: hovering a row drew a highlight round its first words —\nthe half that isn\u0027t the link — and stopped where the link began. So the\nsentence goes back to being a sentence, and what opens the call is a mark of\nits own under the row, where what it opens appears. Braces, because what is\nbehind it is the JSON, with the arrow the leaks screen folds with in front.\n\nAnd what a call sent now names the tool as well as the arguments. `tool` has\nthe name, but this field is read as one thing, and a set of arguments lifted\naway from what they are arguments to is the one form of a call nobody can\nread on its own. `describe_object { … }` beside \"Looked at\" is the pair worth\nhaving when a step doesn\u0027t follow from the one before it.\n\nCo-Authored-By: Claude Opus 5 \u003cnoreply@anthropic.com\u003e\n"
    },
    {
      "commit": "9081eb84ef92190483e82ae71cc4c07015ca95c3",
      "tree": "11bc5ced7e5f8cff8aa312f85c4b9420a18b39fb",
      "parents": [
        "9cb24da5bc77de6416e51d28163e242ac4d75bd9"
      ],
      "author": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Sat Aug 29 09:42:04 2026"
      },
      "committer": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Mon Aug 31 14:39:13 2026"
      },
      "message": "Keep what an agent sent and read back, and unfold a call onto it\n\nThe Agent logs screen was every call as this app\u0027s reading of it — a verb,\nthe object, the agent\u0027s own reason — and a reading is the one thing that is\nno use when the question is why an investigation went wrong. A step taken on\nan answer that said nothing reads exactly like a step taken on one that said\neverything, and nothing on the screen could tell them apart.\n\nSo a call now records the exchange itself: `input` is the arguments as they\narrived, formatted, and `output` is the answer as the text that reached the\nmodel — the same string `toolResult` puts in `content[0].text`, formatted\nonce and then both answered with and written down, so a session can be\ncompared against a client\u0027s own transcript character for character. A\nrefused call keeps no output, its answer having been the refusal the row\nalready prints in full.\n\nEvery row of the screen unfolds onto it, not only the one whose answer is a\nlist of heap dumps, so the verb is a fold on all of them and the thing beside\nit is still the only link. Whole and monospaced, never a first line of it: an\nanswer cut to fit is one where the field that misled the agent is the part\nthat got cut. A session recorded before this says so rather than opening onto\na gap.\n\n`agent_log` hands an agent the same text, which is how one agent works out\nwhere another went wrong — and makes it the one expensive call on this\nsurface, measured at ~9,850 tokens for a five-call session in\nnotes/agent-surface.md.\n\nCo-Authored-By: Claude Opus 5 \u003cnoreply@anthropic.com\u003e\n"
    },
    {
      "commit": "9cb24da5bc77de6416e51d28163e242ac4d75bd9",
      "tree": "1da0836392008b5f6c1d6437c3879512ccc20192",
      "parents": [
        "5713a0cad9d8c780f68cef01975b85873ad1685c",
        "eb8b231faec1f0e58446139d7bb51f0295797e67"
      ],
      "author": {
        "name": "py - Pierre Yves Ricau",
        "email": "pyricau@users.noreply.github.com",
        "time": "Sun Aug 30 09:41:20 2026"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Sun Aug 30 09:41:20 2026"
      },
      "message": "Merge pull request #2968 from square/make-things-easier-for-beginne\n\nMake Shark Dive say less, and say it once"
    },
    {
      "commit": "eb8b231faec1f0e58446139d7bb51f0295797e67",
      "tree": "1da0836392008b5f6c1d6437c3879512ccc20192",
      "parents": [
        "1106cdcd0351fc098cea9036115a6ef6595de582"
      ],
      "author": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Sat Aug 29 13:06:12 2026"
      },
      "committer": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Sat Aug 29 13:18:03 2026"
      },
      "message": "Make setting a verdict a dialog of the tab, not of the window\n\nSetting one was a material3 `AlertDialog`, which draws over the whole window,\nand the step that lists the verdicts a new one contradicts is what that got\nwrong. Two things a reader wants there are behind the scrim. Why two verdicts\ncan disagree at all is a paragraph — every other label in this window answers\nthat with a `?` that opens the reference *in a tab*, and a tab opened behind a\nwindow wide scrim is a tab nobody can reach, so the only way to say it was to\ndraw all 265 characters above the list, every time, for somebody who read it\nonce. And the objects themselves: each verdict being overruled is an object,\nand the reason somebody typed for it is the case for the other reading, so\nweighing it against yours is sometimes going and looking — which a dialog over\nthe window can only offer by being dismissed, and dismissing it throws away the\nhalf typed reason it was holding.\n\nIt looks exactly as it did. `LeakStatusSetter` draws the same centred card over\nthe same 32% scrim, inside the `Box` that holds the tab\u0027s panes rather than\nover the window, so the screen bar and the tab strip above it still take a\nclick. And `SettingVerdict` is state `HeapDumpDive` keeps per tab id rather\nthan `remember`ed inside the composable, so switching tabs leaves it where it\nis and coming back finds the reason half typed. Tab ids are never reused, so an\nentry can only be about the tab it was made for, and closing that tab drops it.\n\nThe paragraph is now the `CONFLICTING_VERDICTS` reference page, and each\nconflicting verdict is a link to its object.\n\nThe three surviving `AlertDialog`s are all about getting a heap dump rather\nthan reading one — `Take heap dump…`, `Bitmaps from the live process`, and the\none a `shark://` link asks — so there is nothing behind them worth reaching.\n\nCo-Authored-By: Claude Opus 5 \u003cnoreply@anthropic.com\u003e\n"
    },
    {
      "commit": "1106cdcd0351fc098cea9036115a6ef6595de582",
      "tree": "53a5112c2871756705e3e5bf3fec1f868a18f9b7",
      "parents": [
        "ec450f5ba0547738bef6b9385463a88f05778e89"
      ],
      "author": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Sat Aug 29 12:53:04 2026"
      },
      "committer": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Sat Aug 29 12:53:04 2026"
      },
      "message": "Say the one thing an empty list or a dialog is there to say\n\nAn empty **Starred** list named the panel the star is in, the empty **Agent\nlogs** screen explained what an agent is and how to point one at the window,\nand a window whose last tab was closed reassured the reader that the heap dump\nwas still open. Each of those is read every time the state comes up, by\nsomebody who has read it before, and only the first clause of any of them is\nnews.\n\nThe dialogs were the same in the other direction: `Take heap dump…` and\n`Bitmaps from the live process` each spent a paragraph on the mechanism above\na list of devices — which Android version puts bitmap pixels in native memory,\nwhat `am dumpheap -b png` needs, what a debugger does to a process while it\nreads them, what `ro.debuggable` is. None of that changes which device gets\nclicked. What does is the cost, so that is what is left.\n\nThe paragraph is not deleted, it moved: `docs/shark-dive.md` now carries the\nbitmap story under **Open a heap dump**, which is where a reader who wants it\ngoes looking, and `notes/bitmaps.md` still has it for whoever is changing this\ncode.\n\nAnd the question a `shark://` link asks when this machine has two heap dumps of\nthat name, or none, now leaves out what the dialog\u0027s own title already says:\nthe title names the file, the rows are the directories, so the question is the\ncount and where to look.\n\nCo-Authored-By: Claude Opus 5 \u003cnoreply@anthropic.com\u003e\n"
    },
    {
      "commit": "ec450f5ba0547738bef6b9385463a88f05778e89",
      "tree": "14ff31a2e08b470a074bb440f77160ae241a368d",
      "parents": [
        "77dfe40ef13168f967e673a8cd11b07895e1e864"
      ],
      "author": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Sat Aug 29 05:57:18 2026"
      },
      "committer": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Sat Aug 29 05:57:18 2026"
      },
      "message": "Say \"held only weakly\" where the legend says Soft, Weak and Phantom\n\nThe line under the strength legend still spelled out \"reachable only\nthrough a java.lang.ref.Reference\" — the phrasing that naming every\nstrength in one or two words was meant to retire, left behind because it\nlives above the map rather than in the details panel where the rest of\nit was.\n\nIt sits directly under rows reading `Soft 0 B`, `Weak 0 B`, `Phantom 0 B`\nand `Finalizer 0 B`, so it was a second vocabulary for four names the\nreader already has in front of them. Which class they have in common,\nand why a dump with none of them is normal, is the page the `?` beside\nthe line already opens.\n\nCo-Authored-By: Claude Opus 5 \u003cnoreply@anthropic.com\u003e\n"
    },
    {
      "commit": "77dfe40ef13168f967e673a8cd11b07895e1e864",
      "tree": "23a4ae235ff010c4d51de5da3ccff467c4cb698a",
      "parents": [
        "7fda56c529a0f6a982d94cf6efad6acaf09f1f8c"
      ],
      "author": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Sat Aug 29 05:55:12 2026"
      },
      "committer": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Sat Aug 29 05:55:12 2026"
      },
      "message": "Name an object the same way on every surface that names one\n\n`ObjectIdentity` — the class, the class in full greyed under it, then\nthe address — was already what a step of a chain, the card at the\npointer and the bar above the map draw. Two other surfaces named an\nobject with a `buildAnnotatedString` of their own instead: a row of the\nobject list said the package, the class and the kind on one line and no\naddress at all, and a leaking object on the Leaks screen said the class,\nthe kind and the address and no package.\n\nNeither was a decision. Each was written beside the list it was for, and\neach read as consistent from where it sat — the two lists of objects\nagreed with each other, and the leaks row agreed with what a leak\u0027s\naddress gets copied for. Which is how one concept ended up with three\nspellings while every commit that touched it looked fine.\n\nSo both call `ObjectIdentity` now, `nameStyle` being the only thing a\nsurface picks. The object list and the starred list gain an address,\nwhich is what a `shark://` link, a note and an agent\u0027s answer all name an\nobject by and what a reader arriving with one has to be able to find\nagain; the leaks screen gains the package, so it says which `Handler` it\nis.\n\nThe tests that matched a row by the old one-line concatenation now match\nit by the simple class name and kind, which is `ObjectIdentity`\u0027s first\nline and already how the chain steps were found.\n\nCo-Authored-By: Claude Opus 5 \u003cnoreply@anthropic.com\u003e\n"
    },
    {
      "commit": "7fda56c529a0f6a982d94cf6efad6acaf09f1f8c",
      "tree": "525380d84f1a83241d8b7d7c21633094363f8fa2",
      "parents": [
        "9e35d63c37d7a8b4049bd9e2da1ee6fbca519ac2"
      ],
      "author": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Fri Aug 28 21:59:23 2026"
      },
      "committer": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Fri Aug 28 21:59:23 2026"
      },
      "message": "Write down what the window is allowed to say\n\nSix commits of this branch deleted on-screen prose, collapsed four\nvocabularies into one, and moved the paragraphs behind a `?`. Nothing in\nthe repo says why, so the next change is free to put them back — and\nlikely to, because every guide, KDoc and note here is written at length\non purpose, which reads as a house style that applies to labels too.\n\nTwo sections in `shark/shark-dive/AGENTS.md`, one for each rule, with the\nmechanism and the worked examples already in the tree. Both name the\nfailure mode as an agent\u0027s: having reasoned out why a label means what it\nmeans, write the reasoning into the label; having learnt a concept, name\nit afresh where it is already named.\n\nThe root guide gets the line that separates the two audiences, since that\nis the one an agent reading this repo would otherwise get backwards.\n"
    },
    {
      "commit": "9e35d63c37d7a8b4049bd9e2da1ee6fbca519ac2",
      "tree": "2d804c299c7ad11304a22df73114313779041193",
      "parents": [
        "a039be7f43584781c64d511e3a4c310d279e2640"
      ],
      "author": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Fri Aug 28 21:57:21 2026"
      },
      "committer": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Fri Aug 28 21:57:21 2026"
      },
      "message": "Make starred a list of objects, kept on disk, and name a size once\n\nTwo lists of objects were two tables. The object list drew a row per\nobject with Shallow and Retained columns; the starred screen drew a\nblock per object, labelled in words nothing else used, out of a copy of\neach object\u0027s sizes taken at the moment it was starred. That copy is\nwhat made it a screen of its own, and it went stale as soon as a verdict\nset by hand changed what something retains, with nothing saying which of\nthe two numbers was current.\n\nSo `ObjectRow` is now the row both lists draw, `StarredFile` keeps the\naddresses and nothing else — one file per heap dump under\n`~/.shark-dive/starred`, in the order they were starred, hand editable\nlike the leak statuses beside it — and what a starred object is, is read\nout of the heap dump again like everything else.\n\nWhich left one vocabulary to settle. The same two numbers were\n`Retained` and `Shallow` in the details panel, `Retains 1.2 MB in 57\nobjects` and `88 B of its own` on the card at the pointer, `Retaining …`\non a chain, and `Retained` over a column. They are now `RETAINED` and\n`SHALLOW` everywhere, with how many objects that is on the `Retained`\nline rather than in a row of its own. The count of objects a rectangle\n*immediately* holds is gone from the panel and the card: it is the\nnumber of rectangles drawn inside this one. `Dominates` therefore means\none thing now, on the chain. An agent still gets `dominatedObjects`,\nhaving no picture to read it off.\n\n`PlaceDetails.dominator` went with it — the starred snapshot was its\nonly reader.\n"
    },
    {
      "commit": "a039be7f43584781c64d511e3a4c310d279e2640",
      "tree": "3e09c29b667b1d7881933279e95810aff6534ebf",
      "parents": [
        "1a10a6868b1a92231b092d97a7a57c6b03aa7686"
      ],
      "author": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Fri Aug 28 21:34:03 2026"
      },
      "committer": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Fri Aug 28 21:34:03 2026"
      },
      "message": "Say how many, once, for a rectangle that isn\u0027t one object\n\nThree kinds of rectangle stand for several objects: the children one had\nno room to draw, every instance of a class the root owns directly, and\nthe uncollected garbage. Each had its own noun — \"300 smaller objects\",\n\"400 objects of one class\", \"Unreachable\" — and under every one of them\nwas a line already saying which kind it was: the rectangle they were left\nout of, the class name, the reachability strength.\n\nSo the noun was the line below said again in a different vocabulary. All\nthree now say the count and stop, and the panel drops the \"Objects\" row,\nwhich was that same count a third time.\n\nThe one difference a reader can act on is whether clicking goes into the\npile or roots the map at what it was left out of. That is what the click\ndoes; it never needed a noun.\n\n\"$nodeCount smaller objects\" was also built in five places and rebuilt in\nthree view tests. It is formatObjectCount now, which is one place and\nwhich puts the thousands separator on a count that never had one.\n\nCo-Authored-By: Claude Opus 5 \u003cnoreply@anthropic.com\u003e\n"
    },
    {
      "commit": "1a10a6868b1a92231b092d97a7a57c6b03aa7686",
      "tree": "d6eb8e2508b4dfc231357f24602efb12a27f8d2c",
      "parents": [
        "42b8f09515947c0cf58f399dad9195b1c0b72eb7"
      ],
      "author": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Fri Aug 28 21:28:10 2026"
      },
      "committer": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Fri Aug 28 21:28:10 2026"
      },
      "message": "Give each reachability strength one name\n\nNine strengths, and the window had three vocabularies for them. The\nlegend above the map said \"Strong\". The details panel beside it said\n\"Strongly reachable\". The leaks screen said \"Softly reachable\" over a\nsection, and \"Held only by a cache that evicts\" was a colour on the map.\nSame nine things, three ways of saying them, so a reader who had learned\nthe legend had to learn the panel and then the list.\n\nReachabilityStrength.label is the one name now — Strong, Cache, Thread\nlocal, Local, Soft, Weak, Finalizer, Phantom, Unreachable — and every\nsurface reads it: the legend, the details panel, the card at the pointer,\nthe chain, the headings of the leaks screen, and the log line saying how\na dump splits up. HeapDominatorTreemap.UNREACHABLE_LABEL was a fourth\ncopy of one of those words and is gone.\n\nWhat a strength *means* is one page,\ndocs/shark-dive-reference/reachability-strength.md, behind the `?` on the\nlegend\u0027s \"Colour\" head, on the strength row of the details panel, and on\neach of the five leaks sections a strength names. One `?` for the nine\nrows rather than one each: nine question marks in a column is nine times\nthe ink for one answer.\n\nSo the five sections lost their explanation. Each was a paragraph saying\nwhat the strength means, which is now what the page says, and two copies\nof that is one that goes stale. The agent\u0027s JSON drops the key for them\nrather than sending null.\n\nThe test about uncollected garbage counts rather than finds: the pile,\nthe tab it opens, the chain and the panel all say \"Unreachable\" now,\nwhich is what one name means. What the panel says about a strength is\npinned by the tests using one that isn\u0027t also a pile.\n\nCo-Authored-By: Claude Opus 5 \u003cnoreply@anthropic.com\u003e\n"
    },
    {
      "commit": "42b8f09515947c0cf58f399dad9195b1c0b72eb7",
      "tree": "8b69c472cc976b44e18fb52e3b41b9af02961be2",
      "parents": [
        "560c607516ebde506ee376ba9fb77ea74de4c291"
      ],
      "author": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Fri Aug 28 21:11:02 2026"
      },
      "committer": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Fri Aug 28 21:11:02 2026"
      },
      "message": "Say what to do about a library leak, and stop hiding where it says how\n\nThe Library leaks section said \"there is usually nothing to do about them\nbut wait for a fix upstream\". That is wrong twice over. A leak in a\nlibrary is an issue to file, usually a small fix to send, pressure to put\non a maintainer, and a version to bump once it lands. A leak in the\nframework can\u0027t be fixed from here, but it can often be worked around,\nand it can always be reported so the next Android version doesn\u0027t ship\nit.\n\nSo the section now says only what the section *is* — someone else\u0027s code,\nrecognized by the reference holding it — and adds that this is about who\nfixes it rather than whether it needs fixing. What to do is a page of the\nreference behind the `?`, since it is four cases and not a line. It names\nAndroidLeakFixes, the ten framework workarounds plumber-android already\napplies, which nothing in the window mentioned.\n\nAnd the description Shark carries for the leak is drawn in full with its\nlinks live, through the same markdown the notes and the reference use.\n51 of the 85 descriptions in AndroidReferenceMatchers end in a URL — the\nAOSP change that introduced the leak, or the file it is in — which is\nwhere the way round it usually is. The row showed three lines of plain\ntext with an ellipsis, so for the long ones that URL was the part cut\noff, and for the rest it was there but dead.\n\nReading them needed one thing the note markdown didn\u0027t have:\nNote.ofDocument, where a line break inside a paragraph is the wrapping\nsomebody did to fit a column rather than a line break. A `\"\"\"` block out\nof Shark read as a note is four blocks, three ending mid-sentence.\n\nCo-Authored-By: Claude Opus 5 \u003cnoreply@anthropic.com\u003e\n"
    },
    {
      "commit": "560c607516ebde506ee376ba9fb77ea74de4c291",
      "tree": "b9097b4ccd202380be8780ac84dc5a47ca89f9a2",
      "parents": [
        "51286407bffed45349dca9839725f47a2cfee124"
      ],
      "author": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Fri Aug 28 20:34:00 2026"
      },
      "committer": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Fri Aug 28 20:34:00 2026"
      },
      "message": "Put a ? beside the labels that take a paragraph to explain\n\nFive paragraphs lived in the window: what names a leak, what a leak\nfingerprint is, what shading by leak status does, why nothing is weakly\nreachable, and what the arrows either side of a stretch of the chain do.\nTogether they were 420 words of prose sitting on a work surface, and an\nexpert tool that explains itself out loud is a tool with less room for\nthe heap dump.\n\nThey are now a page each, under `docs/shark-dive-reference/`, and the\nwindow says one sentence with a `?` after it. Hovering the `?` gives that\nsentence; clicking it opens the page as a tab, with every other page\nlisted under it.\n\nThe sentence on screen is the page\u0027s own opening sentence, read out of\nthe same file, so the two cannot drift apart. `ReferenceTest` holds every\nopening sentence to one sentence of at most 25 words, since a tooltip is\nread while deciding whether to look further.\n\nThe pages ship on the classpath, copied there by `copyReference`, rather\nthan being a link to the website: a release then explains itself with the\npages it was built with, instead of following a link to a page written\nabout a newer one. `docs/shark-dive-reference.md` includes the same files\nfor the site, and `exclude_docs` keeps them from being published twice —\nso there is one copy of every sentence, and a test that says the site\npublishes each of them.\n\nThe `?` never fades and is identical for everyone. A hint that decays is\na hint whoever built this stops seeing, and it is gone by the time\nsomebody wonders what a column meant — which is after the work, not\nduring it.\n\nCo-Authored-By: Claude Opus 5 \u003cnoreply@anthropic.com\u003e\n"
    },
    {
      "commit": "51286407bffed45349dca9839725f47a2cfee124",
      "tree": "8c24dd9360d10d307e70c8dc9b45bbad09240d7d",
      "parents": [
        "5713a0cad9d8c780f68cef01975b85873ad1685c"
      ],
      "author": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Fri Aug 28 15:13:04 2026"
      },
      "committer": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Fri Aug 28 15:13:04 2026"
      },
      "message": "Delete the paragraphs saying what the picture already said\n\nFive paragraphs, 159 words, drawn always-visible next to the thing they\ndescribed. Every one of them restated something already on screen:\n\n- The two lines on the card at the pointer both opened \"Not one object\",\n  above a title that is a count. `400 of one class` and `37 smaller\n  objects` say it in the words that were already there.\n- The details panel\u0027s class-group and unreachable paragraphs did the\n  same, then spent the rest of themselves on what a click does — which\n  the click does.\n- The smaller-objects paragraph explained the layout to the reader\n  (\"too small or too many to draw one by one\"), which is a note about\n  the treemap\u0027s implementation, not about their objects.\n\nWhere a paragraph was carrying something real, it moves to the KDoc of\nthe composable it was describing: what a click on each kind of pile does\nand why they differ is worth knowing before editing `GroupLines`, and\nworth nothing on screen.\n\nThe class-group and unreachable piles lose their only line under the\ncolour swatch that way, so they get the one an object already gets,\n`reachabilityText`. A pile and an object now show the same rows in the\nsame order — how firmly it is held, then what it costs — which is a\nbetter answer to \"is this one object?\" than a sentence saying it isn\u0027t.\n\nCo-Authored-By: Claude Opus 5 \u003cnoreply@anthropic.com\u003e\n"
    },
    {
      "commit": "5713a0cad9d8c780f68cef01975b85873ad1685c",
      "tree": "ea7f460d6bf7486f89532062fbd85765a802b185",
      "parents": [
        "73697bb8ca69266b05c85bd4138ea752f2046afa",
        "51b24918d85396a06034dd46ad858a3773d8f491"
      ],
      "author": {
        "name": "py - Pierre Yves Ricau",
        "email": "pyricau@users.noreply.github.com",
        "time": "Fri Aug 28 14:41:55 2026"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Fri Aug 28 14:41:55 2026"
      },
      "message": "Merge pull request #2967 from square/support-and-so-the-question-is\n\nRename Shark Explorer to Shark Dive"
    },
    {
      "commit": "51b24918d85396a06034dd46ad858a3773d8f491",
      "tree": "ea7f460d6bf7486f89532062fbd85765a802b185",
      "parents": [
        "73697bb8ca69266b05c85bd4138ea752f2046afa"
      ],
      "author": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Fri Aug 28 13:37:42 2026"
      },
      "committer": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Fri Aug 28 13:37:42 2026"
      },
      "message": "Rename Shark Explorer to Shark Dive\n\n\"Explorer\" said the app was for looking around a heap dump, which is what it\nwas. It now records verdicts, keeps notes and answers an agent over MCP: an\ninvestigation rather than a browse. A dive is the thing you go on, it is what\nthe shark was already doing, and it says nothing about a window.\n\nThe rename is everything the app is named in: the five modules and their\nGradle paths, the shark.explorer package, the macOS bundle and its shark://\nscheme handler, the release workflows and their tag prefix, the docs and the\nagent skill. Nothing has ever been released under the old name — no\nshark-explorer-* tag and no GitHub release — so no artifact, tag or installed\ncopy is orphaned by it, and there is no migration code for the same reason:\n~/.shark-explorer becomes ~/.shark-dive by hand, on the handful of machines\nthat have run it from source.\n\nLeakCanary\u0027s own on-device HprofExplorerScreen keeps its name. It predates\nthis app and is a different feature.\n\nCo-Authored-By: Claude Opus 5 \u003cnoreply@anthropic.com\u003e\n"
    },
    {
      "commit": "73697bb8ca69266b05c85bd4138ea752f2046afa",
      "tree": "4b011c659ae7b5602d8365fd4ae65862d640ee3b",
      "parents": [
        "0f7dbab17e2a9f6f310f7b8be214e029bfc502e3",
        "c0f2d2fe1acedf2289a987ac423a32ce989f8b62"
      ],
      "author": {
        "name": "py - Pierre Yves Ricau",
        "email": "pyricau@users.noreply.github.com",
        "time": "Fri Aug 28 13:14:48 2026"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Fri Aug 28 13:14:48 2026"
      },
      "message": "Merge pull request #2966 from square/investigate-root-cause\n\nLet an agent investigate the heap dump in a window somebody is watching"
    },
    {
      "commit": "c0f2d2fe1acedf2289a987ac423a32ce989f8b62",
      "tree": "4b011c659ae7b5602d8365fd4ae65862d640ee3b",
      "parents": [
        "5716c2fdb7f51a2787f97742a7a8e4f87d8019fc"
      ],
      "author": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Fri Aug 28 12:55:54 2026"
      },
      "committer": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Fri Aug 28 12:55:54 2026"
      },
      "message": "Look a link\u0027s heap dump up when the window that has it failed to open it\n\nA window keeps the file it was given even when opening it failed, so it went on claiming every link\nabout that name and every one of them landed on the error message. The file is usually openable from\nwhere this machine last saw it — the case that found this was a run started with a relative path,\nwhich the OS resolves against `/` for an app it launched — so a window whose heap dump failed to open\nis no longer a window that has it, for links and for what this run claims from its peers.\n\nAnd say plainly there is no file there, with the absolute path, rather than letting the parser report\nthe path as it was given: `/shark/shark-android/…/leak_asynctask_o.hprof` is what says the working\ndirectory wasn\u0027t the checkout, where the relative path it was typed as says nothing at all.\n"
    },
    {
      "commit": "5716c2fdb7f51a2787f97742a7a8e4f87d8019fc",
      "tree": "f420f2dd17ec5a199cda582bbf94abbee2303753",
      "parents": [
        "2370e15ae95ab10eb20a6bab7d220226fc8b0676"
      ],
      "author": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Thu Aug 27 17:32:35 2026"
      },
      "committer": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Thu Aug 27 17:32:35 2026"
      },
      "message": "Say only which heap dump a link is about, and ask when that isn\u0027t enough\n\nA link was carrying the window it was copied from, then the heap dump\u0027s whole path, and both were\nanswers to a question that hardly ever comes up: which of the heap dumps called that. Heap dump file\nnames are as good as unique — this app names every dump it pulls off a device after the process, its\npid and a random number, and LeakCanary names its own after the time of the dump — so a link that\nsays `shark://bug-4821.hprof/leaks` names one file on any machine that has it, and the long form was\npaying for the rare case in every link.\n\nSo the name is the whole of what a link says, and the rare cases are asked about rather than encoded:\n\n- A window has that heap dump open, which is nearly always. That window is where the link goes.\n- None has, but this machine has had it open. It opens, from where it was last seen.\n- Two heap dumps of that name, open or on record. The reader picks, by path.\n- Nothing here knows the name — a link from another machine, a dump deleted or opened long ago. The\n  reader is asked for the file, since silence can\u0027t be told from the app having failed to start.\n\nOne dialog puts both questions, because both answers are a path. `\u0026dump\u003d` is still there for a link\nthat wants to say where the file is, and it does the picking for the reader when it does.\n"
    },
    {
      "commit": "2370e15ae95ab10eb20a6bab7d220226fc8b0676",
      "tree": "f80c5917f8f72bf3fb64b13c07b0d06af5b8a618",
      "parents": [
        "c0297d6020c850545762cd4d62c35f36f1c8eb43"
      ],
      "author": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Thu Aug 27 14:50:36 2026"
      },
      "committer": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Thu Aug 27 14:50:36 2026"
      },
      "message": "Look up where a heap dump is rather than say it in the link\n\nA copied link was 220 characters, four fifths of them a percent encoded\npath that says nothing to whoever reads it:\n\n    shark://leak.hprof/object?id\u003d0x12d368b8\u0026dump\u003d%2FUsers%2F…\u0026window\u003dzvphq4r3\n\nThe path was in there because a link has to outlive the run it was copied\nfrom, and nothing else remembered where the file was. So something else\ndoes: HeapDumpPaths writes down the path of every heap dump that opens,\nunder the id of the window it opened in, the newest 200 kept, and\nfollowing a link is a lookup. The same link is now\n\n    shark://leak.hprof/object?id\u003d0x12d368b8\u0026window\u003dzvphq4r3\n\nWhat it costs is that a link works for as long as this machine remembers\nthe file rather than for as long as the file exists — and a link that has\nbeen forgotten says so, and can still be given \u0026dump\u003d\u003cpath\u003e by hand,\nwhich is also the answer for a dump this machine has never opened.\n\nRecorded under the window id rather than the dump, because that answers\nboth questions with one file per open: a window id resolves to the dump\nit was showing, and a file name to the newest record with that name. So\n`shark://\u003cwindow id\u003e/\u003cplace\u003e` — the shortest a link can be, and what this\napp used to write — opens the right heap dump again after every window of\nit has gone, instead of finding nothing.\n\nA run with no window records its dumps too, which is what makes the link\n`show` hands back from a --no-ui run resolve for the next reader.\n"
    },
    {
      "commit": "c0297d6020c850545762cd4d62c35f36f1c8eb43",
      "tree": "689d1d9116faf17b7cd1df8cab9e729a6909ec19",
      "parents": [
        "102eb34412ee05480e56acecab72c7d0fadbc483"
      ],
      "author": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Thu Aug 27 14:02:42 2026"
      },
      "committer": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Thu Aug 27 14:02:42 2026"
      },
      "message": "Name the heap dump in a link, and the window only as a refinement\n\nA link was `shark://\u003cwindow id\u003e/\u003cplace\u003e`, which made it die with the window\n— and that is most links a day later, and most links in an agent\u0027s session\nlog, since a session outlives the run that wrote it. Every place a link can\nname belongs to the heap dump rather than to whatever is showing it, so the\ndump is now the identity:\n\n    shark://\u003cfile name\u003e/\u003cplace\u003e?\u003cplace\u003e[\u0026dump\u003d\u003cpath\u003e][\u0026window\u003d\u003cid\u003e]\n\nThe file name is the authority because it is what a person reads and types\nand what every agent answer already carries; `dump\u003d` is the normalized path,\nsince two `com.squareup.hprof` off two devices are two investigations and a\npath is the only thing that can open a dump nothing has open. `window\u003d` is\nhonoured while that window exists and ignored once it doesn\u0027t, rather than\nturning the link into an error — which keeps the case the window id was for,\nthe same dump open twice being two readings of it compared.\n\nLinks already written into notes and session files still resolve: the last\nstep of `ExplorerWindows.windowFor` matches the authority against a window\nid. A run claims a link only for a window it already has, never for a file\nit could open, or every run would claim every link.\n\nTwo things this unlocks, and the reason to reverse it rather than live with\nit. A `--no-ui` run answers `show` with a link now — no window and the file\nall the same — instead of saying there was nothing to hand back. And every\n*Agent logs* row about another heap dump has a link to copy, where before\nthere was nothing to send.\n\nOn the agent surface the argument is `heapDump`, taking a file name, with a\nwindow id only where a name cannot answer.\n"
    },
    {
      "commit": "102eb34412ee05480e56acecab72c7d0fadbc483",
      "tree": "f1aace61afdf5bf5fbce76242538937b49414d92",
      "parents": [
        "5a5f9bad1eaaf75b138768b3470ad5f4d3e4eb7f"
      ],
      "author": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Wed Aug 26 08:01:11 2026"
      },
      "committer": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Wed Aug 26 08:01:11 2026"
      },
      "message": "Link the thing a call was about, and give every call one\n\nA row of the Agent logs screen is a sentence with one link in it, and the link\nwas the whole sentence whenever the call named nothing: \"Listed the leaks\" and\n\"Read the agent log\" were underlined verbs. A verb now stops where the thing\nstarts — \"Listed the\" and then *leaks* — which needs the words for a screen to\nlive beside the tool names rather than being taken from the tab titles, since a\ntab says `Leaks` and a sentence says leaks.\n\nTwo calls had no link at all, and both are things a person does in this window:\ndominator_tree with no object is the tree from its root, and find_objects with\nno className is the object list unfiltered, which also stops that row reading as\n\"Searched for in com.squareup.hprof\". AgentScreen is the words and the place as\none value, read by AgentTools.placeOrNull and by the screen, so a screen cannot\nbe reachable and unnamed or named and unreachable. Sessions already on disk have\nno link for those two, so the place is worked out from the tool when the file\nsays none.\n\nThe call that asks which heap dumps are open answers with a list, so its row\nunfolds into them: each dump a window away, the one this window has open marked\nand leading nowhere. Which needs the answer recorded, like a conclusion — the\nrun that had them open has ended by the time anybody reads it.\n\nAnd every session row leads somewhere now. One about another heap dump opens in\na window of that dump as before; the ones with no such window to be had — a dump\nthat has been deleted, or a client that read none — are read here rather than\nbeing dead rows, since an address whose file has gone resolves to nothing in any\nwindow there is, and the verbs, the reasons and the refusals are still what the\nagent said. The group of a dump that has gone says so.\n\n\"Described\" is gone: it read as the agent having written a description rather\nthan having asked what an object is. Every read now starts with Read, Looked,\nListed, Searched or Asked, and every write with Recorded, Took, Wrote, Showed,\nConcluded, Opened or Dumped.\n"
    },
    {
      "commit": "5a5f9bad1eaaf75b138768b3470ad5f4d3e4eb7f",
      "tree": "49b7ab97964a5a679ad4d5cc3a0cdb748a3a2190",
      "parents": [
        "5faeb13a35fbf6f97322a756087f5971dc7ae53a"
      ],
      "author": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Wed Aug 26 07:10:16 2026"
      },
      "committer": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Wed Aug 26 07:10:16 2026"
      },
      "message": "Tell an agent this surface exists, and that it isn\u0027t only about leaks\n\nThe tools were reachable and undiscoverable: MCP needed a client someone had\nconfigured, and `--agent` needed somebody to say it was there. So the skill —\n`.claude/skills/shark-explorer/SKILL.md`, which is the directory every client\nthat reads the standard looks in, so it is the project skill here and the\ndirectory a user copies into `~/.claude/skills`. It frames the three cases an\nagent is actually in — something already open, a file to open, a device to dump\n— rather than assuming a window is up, and it points at `--agent-help` and at\nthe method the tools hand over rather than repeating either.\n\nThe description is not leak-only, and neither is the surface any more: taking a\ndump used to say \"call list_leaks to see what the dump says about itself\", which\npoints a dump somebody took because the app was using a gigabyte away from the\nquestion. It names dominator_tree too. And `find_objects` now says that with no\nclassName it is the biggest objects in the dump, which is what \"what\u0027s big in my\nheap\" needs and which nothing said.\n\nChecked by doing it: `--agent dump_heap` took a 146 MB dump of com.squareup off\nan API 29 emulator in 90 seconds and opened it, and `dominator_tree` and\n`find_objects` on that window found the class loader\u0027s 42 MB of classes and,\nbelow it, a Coil image cache holding a 4 MB bitmap. The eval note has the plan\nfor scoring that as a scenario, including why the class loader answer is the\nfailure a \"what\u0027s big\" dump has to provoke.\n\nCo-Authored-By: Claude Opus 5 \u003cnoreply@anthropic.com\u003e\n"
    },
    {
      "commit": "5faeb13a35fbf6f97322a756087f5971dc7ae53a",
      "tree": "0e61fe65f865d13a5837362b2cbf8bc3f9d0c20a",
      "parents": [
        "f18da6c496e0288604f96349bf35ae51a4f801a5"
      ],
      "author": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Wed Aug 26 06:59:14 2026"
      },
      "committer": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Wed Aug 26 06:59:14 2026"
      },
      "message": "Head each heap dump\u0027s agent logs with the file, this window\u0027s first\n\nThe screen listed this window\u0027s sessions and then \"Other heap dumps\", which\nburied the one thing a reader needs to know about a session: which dump its\naddresses belong to. Now it is \"Agent log\" over one group per heap dump, headed\nwith the file name — numbered when two dumps share one — and this window\u0027s group\nfirst, saying so.\n\nA row of a session was clickable end to end, so clicking the word \"Described\"\nnavigated. Only the object leads anywhere now, unless the call named none, where\nthe verb is the whole sentence and is the link itself.\n\nWhich also fixes \"Listed the leaks Leaks\": the window named a place derived from\nthe tool rather than from an argument, so the leaks screen\u0027s title landed after a\nverb that already said it. It only names what the call itself said it was about,\nwhich is one fewer heap dump read as well.\n\nCo-Authored-By: Claude Opus 5 \u003cnoreply@anthropic.com\u003e\n"
    },
    {
      "commit": "f18da6c496e0288604f96349bf35ae51a4f801a5",
      "tree": "eafda17a1289f42688d8a7239c0b72c9dc587c7c",
      "parents": [
        "e4504137a9044638e9360c8d51b547d81db44155"
      ],
      "author": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Wed Aug 26 06:59:06 2026"
      },
      "committer": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Wed Aug 26 06:59:06 2026"
      },
      "message": "Answer an agent with what has already been tried on this heap dump\n\nThe *Agent logs* screen had no tool behind it, which broke the rule that every\nscreen has an agent equivalent — and it is one of the more useful screens to\nhave: an investigation somebody already ran is either the answer or the half of\nthe dump not worth doing again. `agent_log` lists the sessions that read this\ndump, newest first, with what each concluded and how many calls were refused;\nwith `session\u003d`, every call of one in order with the reason the agent gave.\n\nRefused by name for a session that read another dump, because an address is an\naddress of one dump: reading it here would be rows meaning other objects. Which\nis the rule the screen groups by, so both readers of a session file agree.\n\n`dump_heap` said the garbage was collected \"where the device is new enough\",\nwhich was vague and wrong: it is collected either way — `am dumpheap -g` from\nAPI 27, and `JdwpGc` running the same collection in the process below that.\n\nDetekt\u0027s LargeClass now excludes tests, the way TooManyFunctions already does,\nand allows 700: a test class is as long as the story it walks, and AgentTools is\na registry whose length is tool descriptions rather than behaviour.\n\nCo-Authored-By: Claude Opus 5 \u003cnoreply@anthropic.com\u003e\n"
    },
    {
      "commit": "e4504137a9044638e9360c8d51b547d81db44155",
      "tree": "3034fc73b62219d1ec189c6513a3218f1d857833",
      "parents": [
        "ffeb51bfb6e97c1cb32b31cb805e1f227f62f42a"
      ],
      "author": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Tue Aug 25 21:40:24 2026"
      },
      "committer": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Tue Aug 25 21:40:24 2026"
      },
      "message": "Call a tool from a shell, at the window that is already open\n\n`--agent \u003ctool\u003e name\u003dvalue …` makes one call and prints the answer, and\n`--agent-help` says what the calls are with no window, no heap dump and\nno Gradle. For an agent whose client speaks no MCP, and for one that has\na shell and hasn\u0027t been configured with anything.\n\nIt is argument translation and nothing else: it builds a `tools/call` on\nthe loopback socket the run already publishes, so a refusal it prints\nwas thrown by the handler that would have refused an MCP client, and the\nhelp is generated from the same registry `tools/list` answers from. Which\nalso means a call from a shell is not a slower call — measured at 160 to\n180 ms, a JVM starting and a socket, against a heap dump that was parsed\nand indexed once in the window somebody is watching.\n\nWhat a process per call would otherwise lose is the session: a connection\nis what gathers an MCP investigation, and a command per question would be\na row per call on the *Agent logs* screen. So the handshake carries an\noptional session name after the token, `AgentSessionFile.continuing`\nappends to the file that already has it, and a shell\u0027s calls default to\n`cli\u003cits pid\u003e`. Checked at both ends, because it becomes part of a file\nname — refused before anything is called at this end, served with a\nsession of its own and a line in the log at the other.\n\nExit codes carry the rest: 0 with JSON on stdout, 2 with the refusal on\nstderr, 1 when there was nothing to answer it.\n"
    },
    {
      "commit": "ffeb51bfb6e97c1cb32b31cb805e1f227f62f42a",
      "tree": "d551695cbc4378f7205d854d0717c413837a88be",
      "parents": [
        "fef41993f258c239c3b000de39ab12c26569cfb5"
      ],
      "author": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Tue Aug 25 21:10:25 2026"
      },
      "committer": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Tue Aug 25 21:10:25 2026"
      },
      "message": "List the agents that worked on this heap dump, and no others\n\nA window is a heap dump, so the *Agent logs* screen of one is the agents\nthat read *that* dump: `AgentSession.heapDumpPaths` is what decides, and\nthe sessions about another dump are listed under `Other heap dumps` and\nopened in a window of theirs. There is no window that isn\u0027t a heap dump\nfor them to be read in, and read against the wrong one they are rows of\naddresses that mean other objects.\n\nWhich is also why naming an object goes back to the window, undoing the\n`about` recorded on every call: the reader has the heap dump the session\nis about, so it can resolve an address itself, and a session written\nbefore the recording was added no longer reads differently from one\nwritten after. The one row left as an address is a call that went on to\nanother dump, which says which file and opens it.\n\nCo-Authored-By: Claude Opus 5 \u003cnoreply@anthropic.com\u003e\n"
    },
    {
      "commit": "fef41993f258c239c3b000de39ab12c26569cfb5",
      "tree": "664e29d1726220b8e7f6bd64b089db5bca9bb72f",
      "parents": [
        "1826618d68ae99bece7aafe7149143441da28ff9"
      ],
      "author": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Tue Aug 25 17:26:05 2026"
      },
      "committer": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Tue Aug 25 17:26:05 2026"
      },
      "message": "Leave the one place no argument named unnamed in a session\n\n`list_leaks` is the only tool whose place comes from which tool it is\nrather than from an argument, so recording a name for it made its row\nread \"Listed the leaks Leaks\" — the verb already says the whole of it.\n"
    },
    {
      "commit": "1826618d68ae99bece7aafe7149143441da28ff9",
      "tree": "ae661547df08b878177354937b69ce2b0e0b3a3d",
      "parents": [
        "fb7aa3e18bd338e4ae87f5782854a05c1abc01bc"
      ],
      "author": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Tue Aug 25 17:04:59 2026"
      },
      "committer": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Tue Aug 25 17:12:57 2026"
      },
      "message": "Name the object an agent asked about where the heap dump is open\n\nThe *Agent logs* screen drew a bare `0x12d368b8` for most rows, and the\nrows it did name were the only ones that led anywhere. Both came from\nthe same mistake: the window resolved the addresses itself, so it could\nonly speak for calls about the dump it happened to have open, and a\nsession spans every dump that was open while it ran.\n\nSo the name is recorded as the call is made, by the run that has that\ndump open — `AgentTarget.about`, one small read per call — and the\nscreen shows what is written down. A row reads the same in any window\nnow, and a row about another heap dump opens that dump instead of doing\nnothing. It only leads nowhere when the file has been deleted, which it\nsays by naming it.\n\nSessions written before this have no name against their calls, so\n`subject` falls back to the address the agent typed.\n\nCo-Authored-By: Claude Opus 5 \u003cnoreply@anthropic.com\u003e\n"
    },
    {
      "commit": "fb7aa3e18bd338e4ae87f5782854a05c1abc01bc",
      "tree": "3396f8e53c879ed70743b6031cf4e748736e3117",
      "parents": [
        "375ed39bb2d4d68b39cf80149d4406fe1cf00425"
      ],
      "author": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Tue Aug 25 10:01:07 2026"
      },
      "committer": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Tue Aug 25 10:01:07 2026"
      },
      "message": "Measure whether an agent can solve a leak\n\nEvery change to a tool description, a refusal or the method is a prompt\nchange, and a prompt change is not something anyone can review by\nreading it. JProfiler measured theirs and found one model going from\n38/55 scenarios to 55/55 on the same tools with better descriptions —\na change nobody would have predicted from the diff.\n\nSo: heap dumps whose answer key is known before the tools are asked\nanything, a client per run, and a score that is a string comparison and\na count over the session file the server wrote while the agent worked.\nNo model marks anything, because a model judging an answer is a second\nunverified opinion.\n\n`shark-explorer-eval` is the dumps and the scoring — its own module\nbecause writing a scenario needs `shark-hprof-test` in a main source\nset, and that is not a dependency anything shipped should have.\n`harness/eval/run-eval.sh` is the process handling between them.\n\nFour things about it are only there because a run was handed its own\nanswer, and every one of those was found by running it: the dump\u0027s file\nname, the client\u0027s working directory, the notes of the run before, and\nthe notes of the eval before that one. The fourth cost a day and is\nwritten up in notes/agent-eval.md, because it read exactly like a model\ngetting a leak wrong.\n\nCo-Authored-By: Claude Opus 5 \u003cnoreply@anthropic.com\u003e\n"
    },
    {
      "commit": "375ed39bb2d4d68b39cf80149d4406fe1cf00425",
      "tree": "39f8a1a5689de3f55e7ae60b1b7d4cd4c5d89f5b",
      "parents": [
        "c61f06340909d4ba2a97c58c0ac23b64d3356ab9"
      ],
      "author": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Tue Aug 25 10:00:58 2026"
      },
      "committer": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Tue Aug 25 10:00:58 2026"
      },
      "message": "Name the heap dump a run was pointed at while it is still indexing\n\nAn agent whose first call asked what was open, 2.6 seconds into a run\nstarted on a heap dump, was told nothing was open and was not told the\npath it had been started on. So it guessed one, opened a different heap\ndump, and investigated that instead — confidently, and right through to\na conclusion about a dump nobody had asked it about.\n\nA dump that is still indexing cannot be in `openHeapDumps`: a window id\nis a promise that every tool given it answers, and one that isn\u0027t\nreadable can\u0027t keep it. But leaving the path out of the answer\naltogether is worse than either, so `open_heap_dumps` now names what\nthis run was pointed at, and says to call `open_heap_dump` with it\nrather than to go looking.\n\nBoth the run with a window and the run without one had the hole, since\na window exists for as long as its dump takes to index.\n\nCo-Authored-By: Claude Opus 5 \u003cnoreply@anthropic.com\u003e\n"
    },
    {
      "commit": "c61f06340909d4ba2a97c58c0ac23b64d3356ab9",
      "tree": "24732b1c38f706252411fcd3186d54374a373d19",
      "parents": [
        "d13390b2b5592b02b4ed2c23f21f7e7f9bd28cb1"
      ],
      "author": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Tue Aug 25 08:27:43 2026"
      },
      "committer": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Tue Aug 25 08:27:43 2026"
      },
      "message": "Write down what an agent concluded, not only that it did\n\nA session recorded what was asked and never what came back, which is\nfine for every tool that answers with data and wrong for the one that\nanswers with a conclusion. So the row that ends a session read\n\"Concluded about MainActivity\" and the reference — the whole point of\nthe call — was only in the note it wrote.\n\n`outcomeOfTool` reads it off `conclude`\u0027s answer, the screen draws it\nafter an arrow, and the eval now has something to mark against an\nanswer key.\n\nCo-Authored-By: Claude Opus 5 \u003cnoreply@anthropic.com\u003e\n"
    },
    {
      "commit": "d13390b2b5592b02b4ed2c23f21f7e7f9bd28cb1",
      "tree": "59492b10ab3d7b701c758d6461ce844a3b4852d3",
      "parents": [
        "add3fb86f5054696f64823d9598d87b43f145ab3"
      ],
      "author": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Tue Aug 25 08:18:18 2026"
      },
      "committer": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Tue Aug 25 08:18:18 2026"
      },
      "message": "Send an agent to the code, at the version the dump is of\n\nThe method stopped where the tools stop: it says isolating the reference\nis not the root cause, and then leaves the reading of the code — the\nonly place a root cause can come from — as one clause in one bullet. An\nagent that reads the framework on `main` and the app not at all writes\n\"nothing clears this in onDestroy\" about a class the app doesn\u0027t ship,\nwhich is confident, checkable-looking and wrong.\n\nSo the method now says which copy to read and how the dump itself says\nwhich that is, and each claim in it was checked against a real dump\nfirst: `android.os.Build$VERSION` carries `SDK_INT`, `RELEASE`,\n`CODENAME` and `SECURITY_PATCH`; the app\u0027s `ApplicationInfo` carries its\nprocess, its `dataDir`, the APK it was installed from, `minSdkVersion`,\na `targetSdkVersion\u003d` in `seInfo` and `FLAG_DEBUGGABLE` in `flags`. And\n`BuildConfig` is not in a dump at all — its constants compile into their\ncall sites, so the class never loads — which is why the app\u0027s own\nversion is something to ask for rather than look up.\n"
    },
    {
      "commit": "add3fb86f5054696f64823d9598d87b43f145ab3",
      "tree": "31d6f55f83dbab83caea37beb012a74750bd14c1",
      "parents": [
        "9a7cc962dc5ad06c02757730737dcf3ea43c211f"
      ],
      "author": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Tue Aug 25 08:14:41 2026"
      },
      "committer": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Tue Aug 25 08:14:41 2026"
      },
      "message": "Refuse an argument a tool does not take\n\nFound by asking a real dump the question this surface\u0027s own docs\nsuggest: `find_objects` with `query`, which is what the window calls its\nsearch box, matched nothing in particular and answered with the thirty\nbiggest objects out of 86,056 — a list of `ResourcesImpl` and bitmaps in\nanswer to a question about `BuildConfig`. Nothing said no, because\nnothing read the argument.\n\nWhich is the failure this surface is built to make impossible: a wrong\nanswer that reads like an answer. So an argument no tool property\nmatches is refused, naming it and naming the ones the tool takes, and\nthe schema says `additionalProperties: false` so a client that validates\ncan catch it before the call rather than after.\n"
    },
    {
      "commit": "9a7cc962dc5ad06c02757730737dcf3ea43c211f",
      "tree": "52e85a2363cc8bb86c9f421bb475ffdc955fd7f8",
      "parents": [
        "eae7d4dbc602f8703b1d5d68e28b92cf51401149"
      ],
      "author": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Tue Aug 25 08:11:16 2026"
      },
      "committer": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Tue Aug 25 08:11:16 2026"
      },
      "message": "Drag the note\u0027s bottom edge to make it taller\n\nHow much of the window a note is worth is the reading of it against the\nreading of the heap dump, and that changes with the note: a paragraph\nabout which of two caches this is wants four lines, and an argument\nabout a root cause with the chain quoted in it wants the half of the\nwindow it takes to be read while looking at the chain. A number in the\ncode can only be right for one of them, so it is an edge, dragged the\nway the edges between the panes already are.\n\nThe height is per window, beside the pane widths and for the same\nreason: it is how somebody has set their desk up for the job in hand,\nand a note that shrank as they clicked through tabs would be the window\nrearranging itself. And it is never more than a share of the room the\ntab has, because the edge is the only way back — a note dragged tall on\na big screen and then a window made small would otherwise put its own\nedge past the bottom of the screen, and nothing but resizing the window\nwould bring it back.\n"
    },
    {
      "commit": "eae7d4dbc602f8703b1d5d68e28b92cf51401149",
      "tree": "1e8421e493e503ad932682a48eeb9be77153cfc8",
      "parents": [
        "649106bdbdee8d2de08ef5322b1d6b9a9bdba318"
      ],
      "author": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Tue Aug 25 08:01:36 2026"
      },
      "committer": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Tue Aug 25 08:01:36 2026"
      },
      "message": "Answer an agent with the link to what it showed\n\n`show` and `conclude` put a place on screen, which is right once and\nwrong five times: raising a window over what somebody was doing is a\nhand-over that happens now or not at all. A `shark://` link is the same\nhand-over they can take with them, and the answer an agent writes lands\nsomewhere this app can\u0027t reach — a chat window, a pull request comment,\na bug report — so a link in that sentence is the difference between an\nanswer to take on trust and one to go and look at.\n\nSo `AgentHeapDump.show` answers with a `ShownPlace`: the link, or why\nthere was nowhere. One answer rather than two calls, because a link\nnames a window and so whether there is a link and whether anything was\nshown are the same fact — a `--no-ui` run handing out a link would be\nhanding out an address nothing answers to.\n"
    },
    {
      "commit": "649106bdbdee8d2de08ef5322b1d6b9a9bdba318",
      "tree": "910ac95032d62b6506408ec092ca2b937e78596c",
      "parents": [
        "2873d2a74a2aedc924563d5632bb3ff732ca5047"
      ],
      "author": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Tue Aug 25 07:53:56 2026"
      },
      "committer": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Tue Aug 25 07:53:56 2026"
      },
      "message": "Serve an agent that has no window to investigate in\n\nAn agent could reach the tools only through a window somebody had already\nopened, so a machine with no screen — a build server, a heap dump at the end\nof an ssh session, anything driving an agent unattended — had no way in, and\neven on a desktop the first answer was \"ask your human to launch Shark\nExplorer\".\n\nTwo halves. `--mcp-stdio` with nothing running now starts a window, on the\nheap dump its own command line named, and leaves it open for whoever comes\nback to it. And `--no-ui` serves the tools from that process with no window\nanywhere.\n\nOne code path with one call swapped rather than a headless mode beside the\nwindowed one: `AgentHeapDump.show` gained a return, and it is the only thing\nthat differs. Which is the point — the notes and the verdicts were never on\nthe screen, so a heap dump investigated with no window opens in one later\nwith the verdicts, the reasons and the conclusion already on it. `show`\nanswers with why it showed nothing rather than saying it did, because an\nagent telling its human where to look is worse than saying nothing when\nthere is nowhere to look.\n\nTwo things found by driving it for real rather than by reading it:\n\nA window opened for an agent publishes the run before its heap dump is\nreadable, so the agent\u0027s first move is to open the path it was pointed at —\nand that used to make a second window and a second index of the same file.\nOpening a path some window already has now hands back that window, which is\nthe opposite of what the button does, and on purpose: a person clicking\n`Open heap dump…` twice is comparing two readings, an agent naming a path is\nnaming a heap dump. The headless side joins an open already in flight for the\nsame reason, which is also what lets a dump named on the command line be\nindexed in the background instead of holding up the client\u0027s `initialize`.\n\nAnd every bridge session ended with a `SocketException` trace on stderr —\nthe answers thread reading a socket this end had just closed — which is where\nan MCP client collects a server\u0027s log, so a clean exit read as a crash. It\nnow knows the close was its own, and the test asserts stderr stays quiet.\n"
    },
    {
      "commit": "2873d2a74a2aedc924563d5632bb3ff732ca5047",
      "tree": "b83518fa36817b278780c7c5f660b4d3ed861e6c",
      "parents": [
        "2f96751de42f505c5832e8ed2e03a4638516976b"
      ],
      "author": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Tue Aug 25 07:24:29 2026"
      },
      "committer": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Tue Aug 25 07:24:29 2026"
      },
      "message": "Let an agent reach every screen and press every button\n\nThe agent surface answered questions about a heap dump somebody else had\nopened. Which meant an agent that needed the treemap, or a second dump, or\na dump off a device had one answer available: ask your human to click\nsomething.\n\nSo the five tools that were missing:\n\n- `dominator_tree`, the treemap without the pixels. Bounded in both\n  directions, because the answer is a tree and every node is a read.\n- `read_notes`, and `take_note` gaining `replace`. A surface that could only\n  append leaves a wrong conclusion in the notes of the object it is wrong\n  about, under a correction — and the next reader finds both.\n- `open_heap_dump` and `list_devices`/`dump_heap`, which are the two buttons\n  above the map.\n\nThe two that make a window answer once the dump can be *read*, not once the\nwindow exists: everything else here is a read, so a window id handed over\nmid-index refuses every call made with it. That needs a failed open to be\nvisible outside the composition, hence `ExplorerWindow.openProblem` — and\nwithout it a file that was never a heap dump is a call that never comes back.\n\n`AgentHeapDumps` stops being a `fun interface`; the tests get a fake of it\nrather than a lambda. The place vocabulary moves to `AgentPlace.kt`, both\ndirections in one file, which is also what keeps `AgentTools` under detekt\u0027s\nclass size.\n\nRe-measured what the surface costs a client: 16 tools, 18,880 characters of\ndefinitions, up from 13,116 for eleven.\n"
    },
    {
      "commit": "2f96751de42f505c5832e8ed2e03a4638516976b",
      "tree": "3ee202c86eb640f8ce6dac27ff886e00512f4d06",
      "parents": [
        "acf1974b75a86f55f33aa42359539c9a4891fe4b"
      ],
      "author": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Tue Aug 25 06:54:14 2026"
      },
      "committer": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Tue Aug 25 06:54:14 2026"
      },
      "message": "Name the solved leak above the chain, and to an agent\n\nA chain that names a faulty reference is an investigation that is over, and\nuntil now the only way to see that was to find the marked step: a real chain\nis tens of steps, and the pane is scrolled to the last of them.\n\nSo the answer is also said where the eye starts, as a two line `Leak solved`\nsection above the list, and handed to an agent as `faultyReference` at the top\nof the chain rather than left to be found by scanning the steps for isFaulty.\n\nBoth read the same spelling, `PathReference.leakLabel()`, which the leaks\nscreen and the note `conclude` writes now use too — four surfaces naming one\nleak four ways is four leaks to whoever is grepping.\n\nCo-Authored-By: Claude Opus 5 \u003cnoreply@anthropic.com\u003e\n"
    },
    {
      "commit": "acf1974b75a86f55f33aa42359539c9a4891fe4b",
      "tree": "04ccfd6f84714b8e64f3276ddeb86fcce80b2072",
      "parents": [
        "7682d4b48d05456a6f2ce3d14fada8c8b176f4a9"
      ],
      "author": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Tue Aug 25 06:41:58 2026"
      },
      "committer": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Tue Aug 25 06:41:58 2026"
      },
      "message": "Name the objects an agent asked about the way a tab does\n\nA row of a session read `Described 0x12d368b8`, which is the address the\nagent wrote and not what this window calls that object anywhere else.\nNow it reads `Described MainActivity 0x12d368b8`, from the same titleOf\nthe tabs are named by, so that a row and the tab clicking it opens are\nrecognisably one object.\n\nResolved in the window rather than recorded in the session file: an\naddress is what the agent said, and what it stands for is a read of the\nheap dump this window has open — the read that names a tab. Which leaves\na call about another dump as the address it was, since naming it would\nmean reading a dump nobody here has open.\n\nAn agent can also name an address this dump has no object at, and that\ncall is a row too, so the address is asked about before it is named:\ntitleOf throws on an object the graph hasn\u0027t got.\n"
    },
    {
      "commit": "7682d4b48d05456a6f2ce3d14fada8c8b176f4a9",
      "tree": "88cbd9fb8a8b2133862f4d08d734511b4d2ad409",
      "parents": [
        "c22ab2852db0ec2189214df72bdea936183cad35"
      ],
      "author": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Tue Aug 25 06:25:05 2026"
      },
      "committer": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Tue Aug 25 06:25:16 2026"
      },
      "message": "Show what an agent did, in the window it did it in\n\nAn investigation an agent ran and one a person ran are the same\ninvestigation: the same tree, the same verdicts, the same notes. So what\nan agent did belongs in the window, and in words — the *Agent logs*\nscreen is one row per agent that has connected, and opening one is every\ncall it made with the sentence it gave for making it.\n\nA row leads where the call went, so reading what an agent did and going\nto look at it are one move. A call about another heap dump is drawn and\nleads nowhere: an address is an address of one dump.\n\nThe rows are a file, `~/.shark-explorer/agents/sessions/*.jsonl`, one per\nconnection and the newest hundred kept. One artefact with two readers,\nwhich is why the reading half sits beside the writing half: this screen,\nand the eval in notes/agent-eval.md, which now has a session record to\nscore a run from rather than prose to scrape.\n\nThe description of a call is worked out before the call is answered, so a\nrefused call still records what it was asking about — the refusals are\nthe half of a session worth reading afterwards, and one nobody can\nfollow up on is a dead end on the screen.\n\nWhich is also why the refusal for a contradicted verdict is prose now\nrather than a JSON array of the verdicts it disagrees with: a refusal is\nthe one answer on this surface a person reads, and that one was three\nverdicts and their reasons as raw protocol on the screen that exists to\nnot show it.\n"
    },
    {
      "commit": "c22ab2852db0ec2189214df72bdea936183cad35",
      "tree": "3ccc172ae7f6a8c33cfd1c747572963831cf2aac",
      "parents": [
        "ab707a78853b362c06733c36271fc5dc98f5b09d"
      ],
      "author": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Tue Aug 25 05:43:30 2026"
      },
      "committer": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Tue Aug 25 05:43:30 2026"
      },
      "message": "Say STUCK and EXPECTED everywhere, and Shark\u0027s words nowhere\n\nAn agent reported NOT_LEAKING about an object the window called Expected, which\nis one tool with two vocabularies: the person watching and the agent working\ncannot check each other if the words change at the edge of the process. So the\nenum is STUCK, EXPECTED and UNKNOWN now, and those are what the window shows,\nwhat the files keep and what the agent surface takes and answers with.\n\nShark\u0027s LEAKING and NOT_LEAKING stop at the door. LeakFingerprint is the one\nplace that maps to LeakTraceObject.LeakingStatus, because a fingerprint has to be\nthe string LeakCanary computes.\n\nA verdict file written before this has Shark\u0027s words in it, and its rows are\nskipped with a line in the log saying which. That is deliberate rather than a\ncompatibility shim: this app is an alpha and the file is three columns of text.\nIts header now says \"verdict\" too, since that is the word the window uses.\n\nCo-Authored-By: Claude Opus 5 \u003cnoreply@anthropic.com\u003e\n"
    },
    {
      "commit": "ab707a78853b362c06733c36271fc5dc98f5b09d",
      "tree": "2375e4a7173abfd1bd2b3d8f475d183884b6e50f",
      "parents": [
        "dd39c41988bf7d32af65be0fd67e4d0d77cd5217"
      ],
      "author": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Mon Aug 24 09:59:31 2026"
      },
      "committer": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Mon Aug 24 09:59:31 2026"
      },
      "message": "Name a harness window after the task it is for\n\nThe dock reads the file name of the bundle a process was launched from, so every\nwindow of the packaged app is a tile called \"Shark Explorer\" and two harness runs\nare indistinguishable on screen. So clone the app image, name the copy after the\ntitle, and set the two plist keys that name the menu bar — measured, all three\nnames come from different places, which is now in the guide.\n\nThe copy also has to live outside `build/compose`: another Compose task deletes\nthat app image, and a window whose bundle went away under it dies the way a\nwindow launched from source does. `cp -c` clones, so 240 MB costs 80 ms and no\ndisk.\n\nAnd two notes on what this surface should be: what MCP costs a client here\n(3,300 tokens of tool definitions and 1,240 of method, measured rather than\nguessed, against the 17,600 of GitHub\u0027s server), why a CLI and a skill are\nadapters over one registry rather than second implementations of the rules, and\nhow to score whether an agent can actually solve a leak without a model doing\nthe scoring.\n\nCo-Authored-By: Claude Opus 5 \u003cnoreply@anthropic.com\u003e\n"
    },
    {
      "commit": "dd39c41988bf7d32af65be0fd67e4d0d77cd5217",
      "tree": "426f6b1eafece25d105ae475f8077588211cb0ad",
      "parents": [
        "0f7dbab17e2a9f6f310f7b8be214e029bfc502e3"
      ],
      "author": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Fri Aug 21 16:34:54 2026"
      },
      "committer": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Fri Aug 21 16:34:54 2026"
      },
      "message": "Let an agent investigate the heap dump in a window somebody is watching\n\nShark Explorer\u0027s windows now answer MCP, so an agent reads the dump that is\nalready open — the same tree, the same verdicts, the same notes — rather than a\ncopy of its own, and `show` puts what it is looking at on the person\u0027s screen.\n\nThe reason it is a server rather than a set of shark-cli commands is that a\nserver can say no, and refusing is the whole mechanism: it works with any client\nand nothing here ever calls a model.\n\n- Every call takes a `reason`, enforced in AgentTool.call rather than only asked\n  for in the schema, and it lands in the run\u0027s log beside the reads it caused. An\n  investigation becomes something a person can follow afterwards instead of a\n  conclusion they have to trust.\n- A verdict needs a reason another reader can check, and one that contradicts a\n  verdict already recorded is refused with the list of what it disagrees with.\n- `conclude` is refused until the heap dump itself agrees that one reference is\n  at fault, and the refusal says which of the three ways that fails it is. An\n  agent that has narrowed a chain to three unexplained steps cannot report a root\n  cause, however confident it is.\n\nThe method — the LeakCanary method, as prose for a model — is handed over twice,\nin the handshake and again with open_heap_dumps, because some clients drop the\nhandshake\u0027s instructions and a method nobody read is a method nobody followed.\n\nTwo parts to the transport: a run publishes a loopback port and a token under\n~/.shark-explorer/agents, and `--mcp-stdio` is a mode of the same app binary that\npipes stdio to it, since an MCP client can be configured with a command and not\nwith a port that changes every run.\n\nharness/start-harness.sh is how this actually gets tested: it opens a window and\nprints the command that throws an agent with no knowledge of this repository at\nit, prompted with nothing but \"find the root cause\". The documentation quotes\nthat run.\n\nCo-Authored-By: Claude Opus 5 \u003cnoreply@anthropic.com\u003e\n"
    },
    {
      "commit": "0f7dbab17e2a9f6f310f7b8be214e029bfc502e3",
      "tree": "f5c9dc16825f0870ec5a2a623ab56fd96c471591",
      "parents": [
        "e015007807308d263ab993dace571dae9f3084b5",
        "0ac6c67ce819fb02bedab33b6c60fa6dc87ea625"
      ],
      "author": {
        "name": "py - Pierre Yves Ricau",
        "email": "pyricau@users.noreply.github.com",
        "time": "Fri Aug 21 15:26:20 2026"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Fri Aug 21 15:26:20 2026"
      },
      "message": "Merge pull request #2964 from square/index-entry-shrink\n\nShrink the per-object entries of the heap dump index"
    },
    {
      "commit": "e015007807308d263ab993dace571dae9f3084b5",
      "tree": "c3da11c59b9a43fa64a4aee5b0813d6ef0de1663",
      "parents": [
        "21dd4a30e5393ab2575dac5d8ad8eca7a8aaf75f",
        "c18406547bf9245eb78adcadb1beaa77f4592a26"
      ],
      "author": {
        "name": "py - Pierre Yves Ricau",
        "email": "pyricau@users.noreply.github.com",
        "time": "Fri Aug 21 15:16:40 2026"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Fri Aug 21 15:16:40 2026"
      },
      "message": "Merge pull request #2961 from square/verdicts-on-a-loop\n\nSay what holds an object once a verdict is set on the loop above it"
    },
    {
      "commit": "c18406547bf9245eb78adcadb1beaa77f4592a26",
      "tree": "a75379299c0543a9cc82ba59c4e367d005de827c",
      "parents": [
        "6f515338d11d39ac51d4e41d6dd866566b4a5885"
      ],
      "author": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Fri Aug 21 15:07:47 2026"
      },
      "committer": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Fri Aug 21 15:07:47 2026"
      },
      "message": "Take a chain through a leak rather than through a stack frame\n\n`RootPathSearch` puts two kinds of referrer off until there is nothing\nelse left to walk: a reference the reader marked low priority — a running\nmethod\u0027s stack frame among them — and an object that shouldn\u0027t be in\nmemory. Both went on one queue, with nothing to pick between them, so\nbetween two put off ways the shorter one won.\n\nWhich on `leak_asynctask_o.hprof` is the frame. Marking\n`AsyncTask$SerialExecutor$1` `Expected` and `AsyncTask$3` `Stuck` — the\ntwo verdicts that make the chain name the faulty reference — turned\n\n    AsyncTask.SERIAL_EXECUTOR → SerialExecutor$1 → AsyncTask$3 →\n    MainActivity$2 → MainActivity\n\ninto\n\n    Thread → \u003clocal variable\u003e → MainActivity$2 → MainActivity\n\nthe frame being two steps from the activity where the executor is six.\nThat chain has no `Expected` step on it, so nothing crosses to `Stuck`\nand no reference is marked: the two verdicts that identify the leak were\nwhat hid it. Nothing offered the executor back either — the ways a\ndetour could have run are node disjoint paths, and the executor\u0027s route\nreaches `MainActivity$2` through `AsyncTask$3`, which the frame\u0027s route\nalready took.\n\nSo the two kinds are two queues now, a leak above a low priority\nreference, because a leak is an answer and a stack frame is not: an\nobject marked `Stuck` is a reader saying this is the thing to fix, while\na frame answers \"what holds this\" with \"a method is running\".\n\nCosts one more int array the size of the heap dump and a `maxOf` per\nreferrer. A/B on the 38 MB dump: the 2000 chains to its largest 2000\nobjects, identical before and after at 1,713,405 steps, in 14.8–15.2 s\nranked against 15.0–15.8 s unranked. The sweep of the explorer\u0027s leaks\nagainst LeakCanary\u0027s over the ten real dumps here was re-run either side\nand gives the same leak fingerprints on all ten.\n\nCo-Authored-By: Claude Opus 5 \u003cnoreply@anthropic.com\u003e\n"
    },
    {
      "commit": "21dd4a30e5393ab2575dac5d8ad8eca7a8aaf75f",
      "tree": "23a40c5001feface61ebd28444921dfca6f8ca4e",
      "parents": [
        "e24e8ae0024f2fe84a5f9b5a9c1395687cf5b593",
        "93faf0cd5b9bdf77c9edaffce754c4a1f9918dce"
      ],
      "author": {
        "name": "py - Pierre Yves Ricau",
        "email": "pyricau@users.noreply.github.com",
        "time": "Fri Aug 21 15:04:35 2026"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Fri Aug 21 15:04:35 2026"
      },
      "message": "Merge pull request #2963 from square/referrer-index-csr\n\nDelta encode the referrer index instead of a linked list per object"
    },
    {
      "commit": "93faf0cd5b9bdf77c9edaffce754c4a1f9918dce",
      "tree": "d3342401d05675a72c388bfe2ecd9d31833fa7d6",
      "parents": [
        "9ea351d1f00845a4059554d0a1ebcf4778f970c7"
      ],
      "author": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Fri Aug 21 14:52:08 2026"
      },
      "committer": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Fri Aug 21 14:52:08 2026"
      },
      "message": "Say \"514,515 references\" rather than \"514,515 objects references\"\n\n`formatObjectCount` puts the word \"objects\" after the number, which is right for\nthe object count next to it and not for the reference count.\n\nCo-Authored-By: Claude Opus 5 \u003cnoreply@anthropic.com\u003e\n"
    },
    {
      "commit": "0ac6c67ce819fb02bedab33b6c60fa6dc87ea625",
      "tree": "aa120ad0c9632b207a11188cbc01a0c566e99dbc",
      "parents": [
        "96048d4ed9ff4f8e63daeeea2475383232a7ce2c"
      ],
      "author": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Fri Aug 21 14:48:40 2026"
      },
      "committer": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Fri Aug 21 14:48:56 2026"
      },
      "message": "Key the heap dump index by an offset, not by a whole object id\n\nEvery entry of the four object indexes started with the whole object id\nthat keys it: 4 bytes on an Android heap dump, 8 on a JVM one. Ids are\naddresses within one heap, so what they need is set by how far apart\nthey are, not by how large they are. An entry now stores its id as an\noffset from the smallest id in the heap dump, over as many bytes as the\ndistance up to the largest one needs, which the first pass computes by\nreading the id of every object dump record instead of skipping over it.\n\nThe base is the smallest id, so no offset wraps and encoding is\nmonotonic: stored keys sort in the same order as the ids they encode.\nSorting a builder and binary searching a map therefore work on the\nstored bytes, and only keyAt() decodes.\n\nMeasured per dump, over the four object indexes:\n\n  leak_asynctask_o             span 3.35 GB  4 bytes  1414042 (0%)\n  leak_asynctask_m             span 2.33 GB  4 bytes  1579311 (0%)\n  leak_asynctask_pre_m         span 3.97 MB  3 bytes   531914 -\u003e 486529\n  compose_leak                 span 3.86 GB  4 bytes  3221769 (0%)\n  gcroot_unknown_object        span 1.71 GB  4 bytes  5172045 (0%)\n  unloaded_classes-stripped    span 4.07 GB  4 bytes  4111318 (0%)\n  gc_root_in_non_primary_heap  span 3.39 GB  4 bytes  1451409 (0%)\n  safe_iterable_map            span 1.67 GB  4 bytes  4208959 (0%)\n  hashmap_api_25               span 3.47 GB  4 bytes  1692090 (0%)\n  a JVM heap dump              span  536 MB  4 bytes  14.77 -\u003e 10.77\n                                                      bytes per object\n\nSo this buys nothing on most Android heap dumps, and that is not a\nsurprise: ART writes the low 32 bits of a 64 bit address, and the image\nspace and the app heap land far enough apart that the span still needs\n4 bytes. It buys 4 bytes per object on every JVM heap dump, 27% of the\nindex, which is where the index gets big enough to be the binding\nconstraint — gigabytes on the hundreds-of-millions-of-objects dumps that\nshark-cli and Shark Explorer are pointed at.\n\nPer-page bases rather than one global base would shrink Android keys to\n2 bytes, since a page of a sorted index covers a narrow range, but the\nbase of a page is only known after sorting, so the entries would have to\nbe written wide and compacted afterwards. That trades peak memory for\nretained memory, which is the wrong way round on a device, and the\nretained heap test wouldn\u0027t even see the regression.\n\nHprofRetainedHeapPerfTest: only the leak shares expectation moves, from\n0.86 MB to 0.74 MB. The dumps it builds hold 60022 objects with ids\nspanning 60010, which needs 2 bytes rather than the 4 an id takes, and\nthe measured drop is 120044 bytes, i.e. exactly 2 bytes times the 60022\nentries of the one index that is still open at that point. The\nleak_asynctask_o and leak_asynctask_m expectations don\u0027t move, because\nthose dumps stay on 4 byte keys. HprofIOPerfTest doesn\u0027t move either:\nreading an id out of the already buffered source instead of skipping it\nreads the same bytes.\n\nReading a key of any width through a byte at a time loop cost 15% to 17%\nof end to end analysis time, consistently across five alternating runs.\nBinary searching an index calls it on every probe, so the two widths a\nreal heap dump lands on are read as a whole int or long instead. With\nthat, five more alternating runs put the median analysis of\nleak_asynctask_o at 159 ms before and 161 ms after, and of\nleak_asynctask_m at 166 ms before and 167 ms after; indexing goes from\n19 ms to 19 ms and from 25 ms to 26 ms.\n\nEverything here is internal, so there is no ABI change.\n\nCo-Authored-By: Claude Opus 5 \u003cnoreply@anthropic.com\u003e\n"
    },
    {
      "commit": "96048d4ed9ff4f8e63daeeea2475383232a7ce2c",
      "tree": "7dc4677740d55916f1147ee062922cc3a149833f",
      "parents": [
        "e24e8ae0024f2fe84a5f9b5a9c1395687cf5b593"
      ],
      "author": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Fri Aug 21 14:15:34 2026"
      },
      "committer": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Fri Aug 21 14:48:56 2026"
      },
      "message": "Store the index of an object\u0027s class, not its class id\n\nEvery instance and object array in a heap dump belongs to a class that\nhas a class dump record, so an entry doesn\u0027t need the 4 or 8 bytes of a\nclass id: the index of that class among the class dump records is 2\nbytes for the few thousand classes a heap dump has. The first pass now\ncollects those ids so the second can binary search one, and the class\nindex is sorted by class id, so reading its key back at that index is\nall it takes to resolve one.\n\nOn the Android heap dumps in our test resources the four object indexes\ngo from 12.0-14.1 bytes per object to 10.9-12.6, and on a JVM heap dump\nfrom 19.2 to 14.8. The frozen numbers move by exactly what that\npredicts: 2 bytes times the 65814 instances and 5468 object arrays of\nleak_asynctask_o.hprof is 142564 bytes, and what indexing it retains\ndrops by 142561.\n\nIndexing binary searches a class id where it used to write it out, and\nreading an object resolves the index back to one. Neither is measurable\nend to end: over five runs alternating between the two implementations,\nthe median analysis of leak_asynctask_o.hprof is 159 ms before and 161\nms after, and of leak_asynctask_m.hprof 168 ms before and 165 ms after.\n\nA heap dump with an object whose class has no class dump record now\nfails to open rather than failing later: nothing can be read from such\nan object, since its fields are laid out by a class the heap dump\ndoesn\u0027t contain. None of the Android or JVM heap dumps in our test\nresources hold one, including the two that carry unloaded classes.\n"
    },
    {
      "commit": "9ea351d1f00845a4059554d0a1ebcf4778f970c7",
      "tree": "cdb98037ab98ae5d7546a9d61227772f61b84fb0",
      "parents": [
        "2c8638855909acf955d4754887bdb81134c157fd"
      ],
      "author": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Fri Aug 21 14:47:40 2026"
      },
      "committer": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Fri Aug 21 14:47:40 2026"
      },
      "message": "Delta encode the referrer index instead of a linked list per object\n\n`ReferrerIndex` held an int per object and two ints per reference — 5.7 MB on\nthe largest dump in the repo, and the second largest thing an open window\nholds after the dominator tree. It is now one delta encoded byte slice per\nobject: the referrers of an object sorted, deduplicated, and written as the gap\nfrom the one before it, seven bits of a gap to a byte, with the slice\u0027s own byte\nlength in front of it so that an index into the bytes is only needed once per\nfour objects.\n\nThree to six bytes an object against the twelve to fifteen it was, so 2.0 MB\nrather than 5.7 on `large-dump.hprof`, and a session holding a tree and an index\nruns in 98 MB where it needed 106. It is also 20% *faster* at the walk up the\nreferrers a chain is found by, because a slice is a run of adjacent bytes where\nthe list was a pointer chase through two int arrays the size of the dump.\n\nNothing observable changes. The slices count down from the last object of the\ndump rather than up from the first, which costs 0.7% more bytes and hands back\nexactly the order the linked list did — which is load bearing: a breadth first\nwalk takes the first of two equally distant referrers, and the greedy search for\nevery way an object is held blocks the middles of the paths it finds, so the\nother order loses a holder entirely. Checked per object against the linked list\non all ten heap dumps in the repo, referrers and low priority bits both.\n\n`notes/referrer-index.md` has the numbers per dump, the block size curve behind\nthe four, and where this deliberately differs from `parttimenerd/hprof-analyzer`,\nwhose encoding it is modelled on.\n\nCo-Authored-By: Claude Opus 5 \u003cnoreply@anthropic.com\u003e\n"
    },
    {
      "commit": "e24e8ae0024f2fe84a5f9b5a9c1395687cf5b593",
      "tree": "f76c8cc07d08bcfd269e1f878d88c0f2549837ef",
      "parents": [
        "2c8638855909acf955d4754887bdb81134c157fd",
        "7340b3576719ef9ef8b3ed5996a4f61dc488ebc6"
      ],
      "author": {
        "name": "py - Pierre Yves Ricau",
        "email": "pyricau@users.noreply.github.com",
        "time": "Fri Aug 21 14:32:28 2026"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Fri Aug 21 14:32:28 2026"
      },
      "message": "Merge pull request #2960 from square/strip-without-boxing-class-ids\n\nLook a wrapper class up without boxing its class id"
    },
    {
      "commit": "7340b3576719ef9ef8b3ed5996a4f61dc488ebc6",
      "tree": "4aee7ab52250ed259b29c2c9dc596bc8ef60192a",
      "parents": [
        "8e1f3bba03f1cb252d701b506b66cb51958ee403"
      ],
      "author": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Thu Aug 20 14:05:02 2026"
      },
      "committer": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Fri Aug 21 13:51:43 2026"
      },
      "message": "Look a wrapper class up without boxing its class id\n\nWhether an instance wraps a primitive is a lookup by class id, and that\nlookup went to a Map\u003cLong, PrimitiveWrapperClass\u003e, so hashing the key\nboxed the id of every instance dump, LOAD_CLASS and CLASS_DUMP record.\nThat is 24 bytes per record: 431 MB of the 437 MB that stripping a\n1.4 GB heap dump of 17961453 instances allocated.\n\nThere\u0027s one primitive wrapper class per primitive type, so the ids fit\nin an 8 long array that is cheaper to scan than to hash. What stripping\nallocates no longer grows with the heap dump, which matters on Android,\nwhere stripping runs inside the app whose heap was just dumped.\n\nOutput is byte for byte identical on the Android heap dumps in our test\nresources and on heap dumps taken from a JVM with both identifier sizes.\n\nCo-Authored-By: Claude Opus 5 \u003cnoreply@anthropic.com\u003e\n"
    },
    {
      "commit": "6f515338d11d39ac51d4e41d6dd866566b4a5885",
      "tree": "752de824b82139ad2a1b953d01990847c657da26",
      "parents": [
        "2c8638855909acf955d4754887bdb81134c157fd"
      ],
      "author": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Thu Aug 20 15:20:51 2026"
      },
      "committer": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Thu Aug 20 15:20:51 2026"
      },
      "message": "Report no conflict between two verdicts on objects that hold each other\n\nSetting a verdict by hand on an object asks which verdicts already set it\ndisagrees with, and answered that with one `reaches` call: a verdict of\n`Stuck` disagrees with an `Expected` one below it, so the check asked\nwhether the object being set reaches the object already set.\n\nTwo objects of a heap dump reaching each other is ordinary rather than\nexotic, and the sample app\u0027s own `AsyncTask` leak is an instance of it:\nthe task holds the thread running it through `FutureTask.runner`, that\nthread\u0027s stack frame holds the runnable the serial executor wrapped the\ntask in, and that runnable holds the task. So on\n`leak_asynctask_o.hprof`, marking the runnable `Expected` and then the\ntask `Stuck` — the two verdicts that make the chain name the faulty\nreference, which is the whole point of setting them — reported a conflict\nand said the runnable is held by the task, when every chain the window\ndraws has it the other way round.\n\nNeither object on a loop is above the other: which of them a chain shows\nfirst is decided by where that chain enters the loop. So `isAbove` asks\n`reaches` both ways round and a loop is no conflict, which leaves the\ndirection in a conflict that is reported the direction the chains show.\nNothing goes unsaid: a chain that does put one of them above the other\nstill records the disagreement, as a reason reading `Conflicts with`.\n\nCo-Authored-By: Claude Opus 5 \u003cnoreply@anthropic.com\u003e\n"
    },
    {
      "commit": "2c8638855909acf955d4754887bdb81134c157fd",
      "tree": "02ab1064d0648c5a237531e9413cf24f397b0eb7",
      "parents": [
        "8e1f3bba03f1cb252d701b506b66cb51958ee403",
        "45b05cb648719ce03981a9f3bcb027cdd31e4f3c"
      ],
      "author": {
        "name": "py - Pierre Yves Ricau",
        "email": "pyricau@users.noreply.github.com",
        "time": "Thu Aug 20 15:08:17 2026"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Thu Aug 20 15:08:17 2026"
      },
      "message": "Merge pull request #2952 from square/change-it-to-unknown-not-leaki\n\nSay whether an object is stuck, and which reference made it so"
    },
    {
      "commit": "45b05cb648719ce03981a9f3bcb027cdd31e4f3c",
      "tree": "02ab1064d0648c5a237531e9413cf24f397b0eb7",
      "parents": [
        "67a3e45e22ff18b9a69a4acb6c89d88f53ee34aa"
      ],
      "author": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Thu Aug 20 14:18:00 2026"
      },
      "committer": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Thu Aug 20 14:55:39 2026"
      },
      "message": "Mark the faulty reference on the chain\n\nThe chain already said which objects are stuck and which belong in memory.\nWhat it didn\u0027t say is which reference between them is the leak, which is the\none thing to go and change: a reader following the statuses is being pointed\nat what the leak left behind rather than at the leak.\n\nSo the one step that goes from an Expected object straight to a Stuck one\nreads \"Holder.activity · faulty reference\", bold, in the red of the objects\nunder it, and the hover says why that reference and not another.\n\nOnly that shape is marked. A stretch of several references between the two\nverdicts is a fault at one of those steps with nothing saying which, and a\nchain with nothing Expected above the stuck object can have its fault further\nup than the path reaches — a chain of Cleaners nothing recognizes would\notherwise get its top reference marked for being where the walk started. So\nthe mark is either the answer or absent, and setting a verdict by hand is what\nturns a stretch into one step.\n\nCo-Authored-By: Claude Opus 5 \u003cnoreply@anthropic.com\u003e\n"
    },
    {
      "commit": "67a3e45e22ff18b9a69a4acb6c89d88f53ee34aa",
      "tree": "b7d3bbf4dd9fc1527f40f2574def389509394dc0",
      "parents": [
        "b9f15b48c98aa1bc4d454216730dbe1653973a22"
      ],
      "author": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Thu Aug 20 12:25:34 2026"
      },
      "committer": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Thu Aug 20 14:38:35 2026"
      },
      "message": "Say \"Stuck\" and \"Expected\", and name the faulty reference\n\nNo word built on \"leak\" belongs on an object. A leak is one faulty\nreference that should have been cleared, and everything under it is\nretained by that single mistake — so \"Leaked\" on twenty objects points a\nreader at the twenty rather than at the one thing to fix. \"Stuck\" says\nthe object\u0027s situation without accusing it, and it is the only candidate\nthat asks a question instead of closing one: something is holding this,\nwhat? \"Expected\" says its presence in memory is legitimate at this point\nin the app\u0027s life.\n\nNo other analyser has a verdict like this to borrow words from, checked\nbefore choosing: JProfiler classifies objects by reference type and by\nage, YourKit by reachability scope, MAT and dotMemory name places rather\nthan objects. What they share is the frame — JProfiler asks whether\nobjects \"are still legitimately on the heap or if a faulty reference\nkeeps them alive\" — which is where the name for the culprit edge comes\nfrom, and it is now what the leaks screen and the KDoc call it.\n\nAn object nothing reaches any more stays Stuck like any other: it was\nexpected to be gone, and only the collector not having run keeps it\nhere. A fourth value would have made the verdict mean something\ndifferent in one corner of the window.\n\nCo-Authored-By: Claude Opus 5 \u003cnoreply@anthropic.com\u003e\n"
    },
    {
      "commit": "b9f15b48c98aa1bc4d454216730dbe1653973a22",
      "tree": "31467c515bcd1f6b195a3e862eed5f28eb25e73a",
      "parents": [
        "897ab95e0d792071271c510b3c91ea80e93e82ba"
      ],
      "author": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Tue Aug 11 13:45:30 2026"
      },
      "committer": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Thu Aug 20 14:37:58 2026"
      },
      "message": "Say \"Leaked\" and \"Needed\", one word each\n\nThe verdict on an object is read a dozen times down one chain and once\nper row of the leaks screen, so it has to be a label rather than a\nsentence: \"Shouldn\u0027t be here\" and \"Meant to be here\" were neither\nskimmable there nor short enough for a 320dp panel.\n\n\"Leaked\" keeps what the longer wording was for — this repo\u0027s rule is\nthat a leak is a reference and not an object, and \"leaking\" reads as\nthe object leaking something as easily as being the thing left behind,\nwhich the past tense settles. \"Needed\" is what an inspector actually\nrecognizes about the other end. The panel\u0027s header carries the question\nso the three labels don\u0027t have to, in one word like the Retained and\nShallow beside it, and the checkbox that shades them over the map says\n\"Leaked\" too rather than being the one surface left calling it\nsomething else.\n\nCo-Authored-By: Claude Opus 5 \u003cnoreply@anthropic.com\u003e\n"
    },
    {
      "commit": "897ab95e0d792071271c510b3c91ea80e93e82ba",
      "tree": "706cd820e93a8d2ac36a1fdef9ca763ee06c8013",
      "parents": [
        "1733145bc1cc18be725ec7683af421800b5fe9cb"
      ],
      "author": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Tue Aug 11 06:35:24 2026"
      },
      "committer": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Thu Aug 20 14:37:39 2026"
      },
      "message": "Answer \"Should it be here?\" in the panel, and in the leaks\n\nThe status of the object a tab is on moves out of the title row and into\nthe top of \"What it is\", under a header, because it is a conclusion and\nthat panel is the evidence for it. Above the bitmap preview, so a tall\nscreenshot can\u0027t push it out of view, and the \"Set by hand…\" button is\nnow a pencil left of the answer, where the eye already is.\n\n\"Leaking\" and \"Not leaking\" become \"Shouldn\u0027t be here\" and \"Meant to be\nhere\", \"Unknown\" becomes \"Nobody knows\". LeakCanary\u0027s leak traces print\n`Leaking: YES / NO / UNKNOWN`, and matching them was the case for the old\nwords, but calling an object \"leaking\" reads as it leaking something as\neasily as being the thing left behind — and a leak here is a reference,\nnot an object. The words the inspectors\u0027 answer deserves are the ones\nthis module\u0027s own prose already used. Only the display text moves:\nLeakStatus, LEAKING and leakStatusesOf keep Shark\u0027s names.\n\nAnd findLeaks is read through the statuses too, which it wasn\u0027t. Setting\none changes which objects are leaks, not only how one of them reads: mark\nan object leaking halfway up a chain and it becomes a leak, while what it\nholds drops off the list, being only in memory because of it. So the\ncandidate set follows the statuses, RootPathSearch goes round what a hand\nmarked exactly as it goes round what the inspectors found, and the list is\nworked out again per set of statuses. The cost is that a leak fingerprint\nmatches LeakCanary\u0027s only while nothing is set by hand — which is the\nstretch of chain a status is set to move.\n\nCo-Authored-By: Claude Opus 5 \u003cnoreply@anthropic.com\u003e\n"
    },
    {
      "commit": "1733145bc1cc18be725ec7683af421800b5fe9cb",
      "tree": "27071c4522c45245cc98875eaf817b84f62afd2d",
      "parents": [
        "5d886ffd2d5720a689302c92daa124b8e26e9838"
      ],
      "author": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Mon Aug 10 21:04:21 2026"
      },
      "committer": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Thu Aug 20 14:37:39 2026"
      },
      "message": "Write down what a leaking status set by hand means\n\nCo-Authored-By: Claude Opus 5 \u003cnoreply@anthropic.com\u003e\n"
    },
    {
      "commit": "5d886ffd2d5720a689302c92daa124b8e26e9838",
      "tree": "e167b33a825e7bb4011e2826fb2e08658507b1ba",
      "parents": [
        "68ec57b29d3293c36429398de5ce5efd658c08e7"
      ],
      "author": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Mon Aug 10 21:04:20 2026"
      },
      "committer": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Thu Aug 20 14:37:39 2026"
      },
      "message": "Say whether the object a tab is on is leaking, and let a hand change it\n\nUnder the title that says which object, in the colours a chain draws a\nstatus in, since it is the same answer read in one place rather than a\ndozen. Loud for the two statuses that mean something and quiet for the\nthird: most of a heap dump is objects nothing knows either way about, so\na shaded Unknown on every tab would be a banner nobody reads by the time\nit says something.\n\n\"Set by hand…\" asks for a status and the reason, and then, only when\nthere is one, for what the new status disagrees with: every status it\ncannot be true alongside, with the reason each was given, because\nwhoever is about to overrule one is the only person who can weigh the\ntwo. Keeping the new one flips them and says so in their reasons; undoing\nleaves the heap dump as it was. Nothing is written until that is\nanswered.\n\nCo-Authored-By: Claude Opus 5 \u003cnoreply@anthropic.com\u003e\n"
    },
    {
      "commit": "68ec57b29d3293c36429398de5ce5efd658c08e7",
      "tree": "9332046c0ce3ef9ea57ff2c934b24be263b466df",
      "parents": [
        "8e1f3bba03f1cb252d701b506b66cb51958ee403"
      ],
      "author": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Mon Aug 10 21:04:12 2026"
      },
      "committer": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Thu Aug 20 14:37:39 2026"
      },
      "message": "Read a heap dump through the leaking statuses set by hand\n\nSomeone reading a heap dump knows things the inspectors can\u0027t: a cache\nthat is meant to hold what it holds, a destroyed activity deliberately\nkept for one more frame. So a status can be set on an object, and it\nwins over what the inspectors made of it — with the reason kept, since a\nstatus nobody can check makes every other status in the dump worth less,\nand with what it overruled recorded after it.\n\nPassed into every question whose answer it changes rather than held by\nthe tree, because the tree is read from one thread while a window is\ncomposed on another: overrides in the tree would draw a chain from one\nset of them and the row above it from another. findLeaks() deliberately\ntakes none, so that a leak\u0027s fingerprint stays comparable to the one\nLeakCanary prints for the same objects.\n\nTwo statuses can contradict each other, since each decides what the\nobjects above or below it are, so leakStatusConflictsWith answers which\nones before any of them is written.\n\nCo-Authored-By: Claude Opus 5 \u003cnoreply@anthropic.com\u003e\n"
    },
    {
      "commit": "8e1f3bba03f1cb252d701b506b66cb51958ee403",
      "tree": "61d49effc567d3b8239dc9a6a682f3411b8b25f2",
      "parents": [
        "8a176f3eae0ad43de0b467acddd59a2e6321f20c",
        "c7a1a0eef843c9c9fd968eb3412b2ae409c4257f"
      ],
      "author": {
        "name": "py - Pierre Yves Ricau",
        "email": "pyricau@users.noreply.github.com",
        "time": "Thu Aug 20 12:58:06 2026"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Thu Aug 20 12:58:06 2026"
      },
      "message": "Merge pull request #2959 from square/harden-hprof-stripping\n\nStrip heap dumps that Android wrote, arrays too big for an Int, and gzipped ones"
    },
    {
      "commit": "c7a1a0eef843c9c9fd968eb3412b2ae409c4257f",
      "tree": "61d49effc567d3b8239dc9a6a682f3411b8b25f2",
      "parents": [
        "43c979ad64acf4662c6a91c667282b6d432583dd"
      ],
      "author": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Thu Aug 20 12:25:11 2026"
      },
      "committer": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Thu Aug 20 12:25:11 2026"
      },
      "message": "Strip a gzipped heap dump without unpacking it first\n\nA heap dump that\u0027s been shared has almost always been gzipped on the way\n— the Android heap dumps in our test resources compress 3.9x to 4.4x,\nand 5.6x to 5.8x once stripped — so stripping one meant gunzipping it,\nstripping it, and gzipping the result again by hand.\n\nThe file overload now reads gzipped content gzipped, deciding from the\nfirst two bytes rather than from the name, and writes gzipped output when\nthe output name ends with \".gz\", which is what the default output name of\na \".hprof.gz\" input already produced. The two halves are separate\nextensions on the source and sink providers, so the streaming overload\ncan take either one on its own.\n\nCo-Authored-By: Claude Opus 5 \u003cnoreply@anthropic.com\u003e\n"
    },
    {
      "commit": "43c979ad64acf4662c6a91c667282b6d432583dd",
      "tree": "32da41dbd64b79e6b55236fcfdbf5c9066131d80",
      "parents": [
        "a640be8a6f8f10334e6bd35f1205ac18bb89c70d"
      ],
      "author": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Thu Aug 20 12:21:24 2026"
      },
      "committer": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Thu Aug 20 12:21:24 2026"
      },
      "message": "Skip a heap dump info record by the size it really is\n\nThe heap id in a heap dump info record is an Int, which is how\nHprofWriter writes it and how readHeapDumpInfoRecord reads it, but the\nskip in HprofRecordReader and the copy in HprofPrimitiveArrayStripper\nboth counted it as an object id. With 4 byte identifiers those are the\nsame size, and Android — the only heap dumper that emits these records —\nalways uses 4 bytes, so nothing ever noticed. With 8 byte identifiers\nboth moved 4 bytes too far and read the middle of the next sub-record as\na tag: \"Unknown tag 0x00\".\n\nCo-Authored-By: Claude Opus 5 \u003cnoreply@anthropic.com\u003e\n"
    },
    {
      "commit": "a640be8a6f8f10334e6bd35f1205ac18bb89c70d",
      "tree": "bae0c9b3e9eddf5a7320ca7071540f7fc95d5372",
      "parents": [
        "29fc4eeb7226a00c75c56c2a77777e1fa6db6701"
      ],
      "author": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Thu Aug 20 12:19:14 2026"
      },
      "committer": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Thu Aug 20 12:19:14 2026"
      },
      "message": "Write over an array without allocating a copy of it\n\nThe bytes written over the content of a primitive array were built as a\nbyte array of that content\u0027s size, one per array. That is an allocation\nproportional to the largest array in the heap dump — 374 MB of it to\nstrip a 294 MB heap dump pulled from a real app, and a 216 MB heap to\nstrip a 110 MB one holding a byte[104857600] — and it was sized in Int\narithmetic, so a long[268435456] asked for an array of -2147483648 bytes\nand stripping died with a NegativeArraySizeException. That is the half of\n#2777 that the parser fix left behind.\n\nThree patterns are now built per heap dump and repeated over the content\nof each array instead, which is a constant 24 KB whatever the heap dump\nholds: 34 MB allocated for that 294 MB heap dump, an 8 MB heap for the\none with the 100 MB array, and a 2.15 GB heap dump holding a\nlong[268435456] strips in 3 seconds where it used to crash.\n\nCo-Authored-By: Claude Opus 5 \u003cnoreply@anthropic.com\u003e\n"
    },
    {
      "commit": "29fc4eeb7226a00c75c56c2a77777e1fa6db6701",
      "tree": "b5e95b227afb54d09b64c85a4453a5d2b7358be1",
      "parents": [
        "69e8e660596d6cbb92e1cb7094618bb13581e7e6"
      ],
      "author": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Thu Aug 20 12:10:36 2026"
      },
      "committer": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Thu Aug 20 12:10:36 2026"
      },
      "message": "Strip wrapped primitives whatever order the records arrive in\n\nThe stripper learned where a primitive wrapper keeps its value from the\nCLASS_DUMP record of the wrapper class, and could therefore only zero the\ninstances that came after it. HotSpot writes every class dump ahead of\nany instance, so that held for heap dumps from a JVM — which is all the\ntest covering this used — but ART walks the heap in memory order, and\nthat puts the class dump of java.lang.Integer after most of the Integer\ninstances. 91% of the wrapper instances in leak_asynctask_o.hprof and\n98% of those in leak_asynctask_m.hprof kept their value.\n\nNothing about a wrapper needs learning from the class dump: it declares\nexactly one instance field, and a class declares its own fields ahead of\nthe ones it inherits, so the value is at the start of the instance field\nvalues. The class id now comes from LOAD_CLASS, which both writers put\nbefore the heap dump, and the class dump is read to check that layout\ninstead of to derive it — writing a heap dump that looks stripped and\nisn\u0027t is the failure worth being loud about.\n\nThat also drops the search for the \"value\" field name, which was the one\nplace stripping depended on a name rather than on the format, and a\nlatent bug with it: the running field offset was assigned rather than\naccumulated, so it was only ever right for the first two fields.\n\nCo-Authored-By: Claude Opus 5 \u003cnoreply@anthropic.com\u003e\n"
    },
    {
      "commit": "69e8e660596d6cbb92e1cb7094618bb13581e7e6",
      "tree": "1b59cff50d45700621c3ba8d261eb1c4f6a5de64",
      "parents": [
        "8a176f3eae0ad43de0b467acddd59a2e6321f20c"
      ],
      "author": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Thu Aug 20 12:07:33 2026"
      },
      "committer": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Thu Aug 20 12:07:33 2026"
      },
      "message": "Say what stripping a heap dump leaves behind\n\nStripping replaces primitive arrays and wrapped primitives, and copies\neverything else over unchanged. That includes the string records holding\nthe class, field and method names the rest of the heap dump refers to,\nwhich is easy to read as \"no strings survive stripping\" — a heap dump\nfrom Android has nothing but names in those records, but HotSpot writes\nits whole symbol table out, so every string constant of every loaded\nclass is in there too. Someone stripping a JVM heap dump before sharing\nit should know that the constants written in the code go with it.\n\nCo-Authored-By: Claude Opus 5 \u003cnoreply@anthropic.com\u003e\n"
    },
    {
      "commit": "8a176f3eae0ad43de0b467acddd59a2e6321f20c",
      "tree": "dd949d4bdf87e0caf4fc74e783d8432bd8c1f08a",
      "parents": [
        "efe982638661dc6a22d363a94e60c77416cb5074",
        "736486f68cef9b2342fa8e3f435f56c4dafc7170"
      ],
      "author": {
        "name": "py - Pierre Yves Ricau",
        "email": "pyricau@users.noreply.github.com",
        "time": "Tue Aug 11 14:08:36 2026"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Tue Aug 11 14:08:36 2026"
      },
      "message": "Merge pull request #2953 from square/in-shark-explorer-in-leak-asyn\n\nGive each way out of memory its own section on the leaks screen"
    },
    {
      "commit": "736486f68cef9b2342fa8e3f435f56c4dafc7170",
      "tree": "968c39f170d16c3a92ba6369ffdedbb41332dd88",
      "parents": [
        "629874ff34f00e3fbdf3ba605faebc3f97b94696"
      ],
      "author": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Tue Aug 11 13:56:22 2026"
      },
      "committer": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Tue Aug 11 13:56:22 2026"
      },
      "message": "Fold the sections nobody has to act on under one heading\n\nFive of the seven sections say the same thing: this object shouldn\u0027t be in\nmemory, and nothing you do will get rid of it any sooner. Drawing them\nlike the app\u0027s own leaks makes a heap dump with one leak in it read as a\ndump with seven kinds of problem, so they go under one heading, folded,\nquieter than a section heading and with no band or bar of their own.\n\nFolded says what is in it — \"On their way out · 3 phantom reachable, 2\nunreachable\" — so that folding hides the rows and never the answer, and\nthe paragraph about what being on the way out means waits until someone\nhas pressed it. The count line above the list is split the same way: how\nmany leaks there are to do something about, then how many objects are\nleaving on their own.\n\nWhich half a section is in is LeakKind.isOnTheWayOut, and it is exactly\nthe sections a reachability strength names. The heading\u0027s own folded state\ngoes in the set of unfolded groups, because it is the same kind of thing —\nwhat the reader has asked to see — so it is kept in a note and carried by\na link like the rest of it.\n\nCo-Authored-By: Claude Opus 5 \u003cnoreply@anthropic.com\u003e\n"
    },
    {
      "commit": "629874ff34f00e3fbdf3ba605faebc3f97b94696",
      "tree": "e3c54df3cc6381af32b1b62a7acd288ed2ab16c8",
      "parents": [
        "42dfd2a55deebb363306801ae40b96bc644ccc0a"
      ],
      "author": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Tue Aug 11 13:55:15 2026"
      },
      "committer": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Tue Aug 11 13:55:15 2026"
      },
      "message": "Name a leak on its way out after the reference that hasn\u0027t let go\n\nA phantom reachable view was named after its class, which says what the\nobject is and nothing about why it is still in memory. The answer is one\nstep above it on the chain: the first reference holding no more firmly\nthan the object itself, which is the one the collector hasn\u0027t cleared\nyet. Everything below it is in memory because that reference still is, so\nclearing it takes the lot — which makes it what tells one of these leaks\nfrom another, the way a suspect stretch of references is what tells one\napp leak from another.\n\nOn leak_asynctask_o.hprof the phantom section goes from three rows named\nDecorView, OverflowMenuButton and ImageButton to one named\nCleaner.referent with the three of them under it: one `Cleaner` that\nstill has its referent, holding a screen\u0027s worth of views.\n\nThe fingerprint follows the name and keeps the section in it, so that the\nsame reference under two strengths stays two leaks. Unreachable is the\none section left named after a class — nothing holds those objects, so\nthere is no reference to name them after, which is also why it is the one\nsection whose groups still carry a sentence saying what being in it\nmeans.\n\nCo-Authored-By: Claude Opus 5 \u003cnoreply@anthropic.com\u003e\n"
    },
    {
      "commit": "efe982638661dc6a22d363a94e60c77416cb5074",
      "tree": "10f57f067a2486127360e51a4b8bc4a3fecfa96a",
      "parents": [
        "6adb1884b9c8a72f9eed2ee12eeec147c6e82828",
        "5be4d47361b89691bfd73286abfa97b99fd36a8b"
      ],
      "author": {
        "name": "py - Pierre Yves Ricau",
        "email": "pyricau@users.noreply.github.com",
        "time": "Tue Aug 11 13:39:14 2026"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Tue Aug 11 13:39:14 2026"
      },
      "message": "Merge pull request #2954 from square/why-do-long-paths-display-gc-r\n\nDraw the whole chain from a GC root, however long it is"
    },
    {
      "commit": "42dfd2a55deebb363306801ae40b96bc644ccc0a",
      "tree": "99b867b43e4f61ef0a1bc2dda8f85e92a786ad5b",
      "parents": [
        "577bce2ed52d991ca759a63144ef7c3f48e0fb0e"
      ],
      "author": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Tue Aug 11 07:10:24 2026"
      },
      "committer": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Tue Aug 11 07:10:24 2026"
      },
      "message": "Give each way out of memory its own section on the leaks screen\n\nRather than dropping the objects the collector clears on its own, which was the\nprevious commit and hid twelve of this dump\u0027s twenty six behind a log line. They\nare an answer — an object that was meant to be gone and is on its way is worth\nsaying — and which way it is going is a different answer each time, so it is a\nsection each: softly reachable, weakly reachable, waiting to be finalized,\nphantom reachable, and the unreachable one that was already there.\n\nLeakCanary can tell none of them apart. Its analysis follows no soft, weak or\nphantom referent, so every one of these is an object no GC root reaches as far as\nit is concerned, and the whole lot goes in the bucket this screen has been\ncalling unreachable. Splitting that bucket is the thing this window can do that a\nleak trace can\u0027t.\n\nNone of these groups is named after a chain. What holds a phantom reachable view\nis a Cleaner forty links down a static list, and naming the leak after the\ncanvas pool at the top of that list said nothing about the view: the class is\nwhat tells two objects of one section apart, and the section title says the rest.\nThe chain is still read, because which other leak holds an object is what folds\none leaked screen\u0027s worth of them into one row instead of nine.\n\nCo-Authored-By: Claude Opus 5 \u003cnoreply@anthropic.com\u003e\n"
    },
    {
      "commit": "5be4d47361b89691bfd73286abfa97b99fd36a8b",
      "tree": "10f57f067a2486127360e51a4b8bc4a3fecfa96a",
      "parents": [
        "6adb1884b9c8a72f9eed2ee12eeec147c6e82828"
      ],
      "author": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Tue Aug 11 06:25:08 2026"
      },
      "committer": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Tue Aug 11 06:25:08 2026"
      },
      "message": "Draw the whole chain from a GC root, however long it is\n\nA chain past twenty steps was cut at the root end and the head row said\n\"GC root: …, then … 14 steps\" instead. That answers \"what holds this?\"\nwith a count of the objects that would have said, in the one pane that\nexists to answer it, and there is no way from the count back to them.\n\nChains get long. On the dumps in this repo the deepest are 499 steps\n(large-dump.hprof) and 1,518 (compose_leak.hprof), both walks down a\nlinked structure, so this is not a rare shape.\n\nWhat the cut was protecting was the drawing, not the reading. Reading\none is a heap dump read per step: 6 ms for the 499 and 12 ms for the\n1,518, against a hover\u0027s budget of a hundred. Drawing was the problem —\nthe pane was a Column in a verticalScroll, which composes every row, and\na chain of 1,500 rows at four lines each never appeared at all: 60 s and\nnothing on screen. It is a LazyColumn now, so the pane composes the seven\nrows it has the height for and a chain of 500 steps takes the same 200 ms\nfrom the click as one of 20.\n\nThe independent paths a stretch of a chain could have run instead lose\ntheir cut too, for the same reason: a way of holding an object that stops\nshort is the same non-answer. A chain that long has almost no stretches\nin doubt anyway — the deep ones are linked lists, where every step\ndominates the object, one detour and 18 ms for the 499 and none for the\n1,518.\n\nCo-Authored-By: Claude Opus 5 \u003cnoreply@anthropic.com\u003e\n"
    },
    {
      "commit": "577bce2ed52d991ca759a63144ef7c3f48e0fb0e",
      "tree": "63323ba04281d370a5a7803748168c9d93aeae2a",
      "parents": [
        "6adb1884b9c8a72f9eed2ee12eeec147c6e82828"
      ],
      "author": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Tue Aug 11 06:21:37 2026"
      },
      "committer": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Tue Aug 11 06:21:37 2026"
      },
      "message": "Leave what the collector is about to take off the list of leaks\n\nThe leaks screen listed every object the inspectors said shouldn\u0027t be here,\nhowever weakly it was held, and only made an exception for the unreachable\nones. So a destroyed view whose last holder is a Cleaner — phantom reachable,\nalready finalized, waiting for the runtime to run a thunk and let go — was an\napp leak to go and fix, named after whatever the walk up to the roots ran into.\nOn leak_asynctask_o.hprof that was three views named `DisplayListCanvas.sPool`,\none of them reached through forty one `Cleaner.prev` steps.\n\nLeakCanary reports none of them, and finds them the same way: its leaking object\nfinder returns all 26 objects of that dump, the three included, and they are\ndropped one step later because its analysis follows no soft, weak or phantom\nreferent, so nothing reaches them and they never make it into a report.\n\nSame answer here, decided a step earlier and on the strength rather than on\nwhether a path turned up: an object the garbage collector releases on its own is\nnot the app\u0027s to fix. It stays on the map, where what it says is which bytes\nhaven\u0027t come back yet.\n\nCo-Authored-By: Claude Opus 5 \u003cnoreply@anthropic.com\u003e\n"
    },
    {
      "commit": "6adb1884b9c8a72f9eed2ee12eeec147c6e82828",
      "tree": "eb9d27b015a4a456b0b5c63e41ae4db0f09a2d96",
      "parents": [
        "ca8c455806b5af26627470f71960d499d0915463",
        "a4b0b33f55fd81c058efd4060825022a2b881bd7"
      ],
      "author": {
        "name": "py - Pierre Yves Ricau",
        "email": "pyricau@users.noreply.github.com",
        "time": "Mon Aug 10 18:05:37 2026"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Mon Aug 10 18:05:37 2026"
      },
      "message": "Merge pull request #2951 from square/add-ability-to-add-save-notes\n\nTake a note on any tab, in markdown that knows the heap dump"
    },
    {
      "commit": "a4b0b33f55fd81c058efd4060825022a2b881bd7",
      "tree": "eb9d27b015a4a456b0b5c63e41ae4db0f09a2d96",
      "parents": [
        "c2ae9abe791ae537f32ff21218488686a408307f"
      ],
      "author": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Mon Aug 10 17:19:43 2026"
      },
      "committer": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Mon Aug 10 17:19:43 2026"
      },
      "message": "Say a note is per place, not per object\n\nThree of the note keys are lists rather than objects, one is a group of\nthem, and the tab a window opens with is the whole heap dump — so \"a note\nper object\" reads as if the object list and the leaks had none, which is\nthe opposite of what noteKey does. The code has always been per Place;\nthis is the wording catching up, with \"location\" as the word the docs use\nsince Place is ours rather than anybody else\u0027s.\n\nAlso says how to throw a note away, which the app has always been able to\ndo and nothing wrote down: open it, delete the text, save. An empty note\nis no note, so the file goes and the mark comes off the tab.\n"
    },
    {
      "commit": "c2ae9abe791ae537f32ff21218488686a408307f",
      "tree": "21a50c00f2f3a829c20a2ae5dfb8b55d865f7fbe",
      "parents": [
        "c21c7e9af9166e67caf6aec77ec220deb2a7215c"
      ],
      "author": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Mon Aug 10 17:06:20 2026"
      },
      "committer": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Mon Aug 10 17:06:20 2026"
      },
      "message": "Hang the note button under the title, small\n\nWhere a note appears is where the button that starts one belongs: under\nthe title it will be about, reading in the order it happens — this\nobject, then write about it. At the far end of the row it kept one\nposition, but it was as far from the title as the window is wide, and it\nread as the window\u0027s button rather than as this object\u0027s.\n\nWhat that costs is a line of every tab nobody has written about, so it is\ndrawn small and with no padding of its own, which also lines its label up\nwith the title above it.\n\nThe tooltip on it is one sentence now. Markdown, class names, addresses\nand shark:// links are what the box can do, and that paragraph is in the\nway of deciding whether to open the box; it is the placeholder inside it\ninstead, which is on screen exactly while it is worth reading.\n\nAnd the wording throughout says what was always true of the code: a note\nis the object\u0027s, not the tab\u0027s. Two tabs on one object are one note and\nshow each other\u0027s writing as it is typed, which is now a test rather than\nonly a consequence of Place.noteKey and one PlaceNotes per place.\n"
    },
    {
      "commit": "c21c7e9af9166e67caf6aec77ec220deb2a7215c",
      "tree": "be8aae0c2b4a0cd34ed4c7a6b7a898cf74bd7fc4",
      "parents": [
        "b33947c48def5a5329dc4dc02dc647cb29a4342d"
      ],
      "author": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Mon Aug 10 16:45:58 2026"
      },
      "committer": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Mon Aug 10 16:45:58 2026"
      },
      "message": "Set the arrows apart from what the tab is about\n\nThe row above the panes was one undifferentiated strip: two arrows, the\ntitle, and the button that starts a note at the far end of it. A rule\nafter the arrows makes it two things instead — how the tab got here, and\nwhat it is on — which is what says the note button belongs to the object\nnamed beside it rather than to the window.\n\nWhich is also the answer to where that button goes. Before the title\nputs the action ahead of its subject and moves the title depending on\nwhether a note exists; hugging the end of the title moves the button on\nevery click, the title being the object the tab is on; under the title\nreads best but is a row of window spent on every tab. The end of the row\nkeeps the reading order and one position, and costs nothing.\n\nIt is drawn at the size of every other button in the bars now, rather\nthan a step smaller: the one thing on screen that says notes exist was\nthe smallest text in a row of them.\n"
    },
    {
      "commit": "b33947c48def5a5329dc4dc02dc647cb29a4342d",
      "tree": "dbfdf7a900ff400a921129286533d3f995b92a32",
      "parents": [
        "0f2c09617411bfeb755c542adda480df2e683a63"
      ],
      "author": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Mon Aug 10 14:40:52 2026"
      },
      "committer": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Mon Aug 10 15:09:06 2026"
      },
      "message": "Say what the middle pane answers, and make the tab\u0027s subject a title\n\nThe panes either side of the map are headed \"What holds it\" and \"What it is\",\nand the map between them was headed nothing at all, which left the two of them\nreading as a pair with a picture wedged between rather than as three questions\nabout one object. It gets \"What it holds\", the name its fold button already used,\nand the fold control moves into that header like the other two.\n\nThe object the tab is on is set as a title where it is named above the panes,\nwhich is the one place in the window where it is the subject rather than a\nmention of an object among others: everything under it, three panes or a list\nand the note, is about that object. At body size it read as one more label in a\nbar of them. ObjectIdentity takes the style for its first line rather than\npicking one, so the same three lines stay the same everywhere else.\n\nCo-Authored-By: Claude Opus 5 \u003cnoreply@anthropic.com\u003e\n"
    },
    {
      "commit": "0f2c09617411bfeb755c542adda480df2e683a63",
      "tree": "5df0924aae3a8c2de6762ab1667b06d2c0f6ffdb",
      "parents": [
        "22a8e37c55be790e9f5e5aae0bf450c1e39d2d79"
      ],
      "author": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Mon Aug 10 14:40:31 2026"
      },
      "committer": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Mon Aug 10 15:09:06 2026"
      },
      "message": "List where an arrow leads on a right click\n\nA tab that has walked twenty objects down a chain is one where getting back to\nwhere the walk started is twenty clicks and a guess about which of them it was.\nRight clicking ← or → now lists everywhere that arrow leads, nearest first and\nnamed the way the tab strip names a place, so any one of them is one click.\n\nNavigationHistory.backEntries and forwardEntries are that list and goBack(steps)\nis the move, clamped rather than checked because the list a click came from and\nthe history it lands in are the same value one recomposition apart. The nearest\nfifteen, since the menu is drawn where the pointer is and one taller than the\nwindow has entries nobody can reach; everything past them is still one click of\nthe arrow at a time away.\n\nAn arrow that leads nowhere keeps the disabled button it was, because an empty\nmenu under the pointer reads as the window having lost the history rather than\nas there being none. Naming the entries is why every place of every history is\nnamed now rather than only the one each tab is on: a menu entry called \"…\" is a\nmove nobody will make.\n\nCo-Authored-By: Claude Opus 5 \u003cnoreply@anthropic.com\u003e\n"
    },
    {
      "commit": "22a8e37c55be790e9f5e5aae0bf450c1e39d2d79",
      "tree": "e043e362054be81345a003d200a0b687ebc785ff",
      "parents": [
        "ca8c455806b5af26627470f71960d499d0915463"
      ],
      "author": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Mon Aug 10 10:15:40 2026"
      },
      "committer": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Mon Aug 10 15:09:06 2026"
      },
      "message": "Take a note on any tab, in markdown that knows the heap dump\n\nAn investigation is a series of questions about one heap dump, and the answers\nhave so far lived in whatever the reader happened to have open beside the app.\nEvery tab now takes a note: ✎ Add Note, at the end of the row that says where the\ntab is, turns into a plain markdown box with save and cancel, and saving draws the\nnote under that row and above the panes. There because a note is about the whole\nof what the tab is showing, and only there once there is one — a tab nobody has\nwritten about is the button and nothing else, and the button goes away as soon as\nthere is a note, which carries its own ✎ Edit.\n\nWhat makes it worth having in the app rather than in an editor is that the names\nin it are read against the heap dump. A class name that exists shows up as its\nsimple name and opens the class in a new tab. Anything starting with 0x that\nresolves reads as \"Holder instance (0x82182c00)\" and opens the object. A\nshark:// link is followed exactly as one arriving from the OS is, so a link\ncopied from a tab goes back to that tab. GitHub URLs shorten the way GitHub\nshortens them, and every other http link opens in the browser. Anything this\ndump has nothing for is left exactly as it was typed.\n\nA note is filed under what its tab is about rather than under the tab or the\nplace: the object, the object list however it is filtered, the leaks however\nthey are unfolded. Keyed on the place itself it would follow the search box, and\nkeyed on the tab it would die with it. So the same object in two tabs, or in two\nwindows, is one note, and the tab strip marks the tabs that have one from a\nsingle directory listing.\n\nThe notes are markdown files in the app\u0027s own directory, one per dump and one\nper place, because a dump is opened from wherever it came from and writing\nbeside it means littering some places and failing on the rest. Nothing is\nwritten before the file has been read, and the button that starts a note is\ndisabled until then, so a slow or failed read can never be written over by an\nempty note. The draft being typed is the run\u0027s rather than the screen\u0027s, so\nleaving the tab half way through a sentence and coming back finds the sentence.\n\nCo-Authored-By: Claude Opus 5 \u003cnoreply@anthropic.com\u003e\n"
    },
    {
      "commit": "ca8c455806b5af26627470f71960d499d0915463",
      "tree": "d48c9603afc14de926d289ddb7f69f1f5c4e5a25",
      "parents": [
        "ba793c1ea35ad298b29e818544ce49c20f141bbf",
        "8cb744706b1a4e08162802fea92fb31ddf80930d"
      ],
      "author": {
        "name": "py - Pierre Yves Ricau",
        "email": "pyricau@users.noreply.github.com",
        "time": "Sun Aug 09 15:53:48 2026"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Sun Aug 09 15:53:48 2026"
      },
      "message": "Merge pull request #2950 from square/look-into-deeplinking-i-want-i\n\nCopy a shark:// link to a tab, and follow one back to it"
    },
    {
      "commit": "8cb744706b1a4e08162802fea92fb31ddf80930d",
      "tree": "d48c9603afc14de926d289ddb7f69f1f5c4e5a25",
      "parents": [
        "e17259c5a3f6b89359cd9fd94af1da9c21f52051"
      ],
      "author": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Sun Aug 09 15:43:42 2026"
      },
      "committer": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Sun Aug 09 15:43:42 2026"
      },
      "message": "Offer a link to copy wherever a place can be opened\n\nCopying a link was on a tab and nowhere else, so a link to the object\nunder the pointer meant opening it in a tab first and right clicking\nthat. It is now the second item of every menu that offers \"open in a new\ntab\" — a rectangle of the map, a row of the object list or of the leaks,\na step of a chain, a field of the details panel, a starred object — and\nthe only item on the two things that name a place without a choice of\nwhere to open it: a tab, and a button of the screen bar.\n\nEvery one of them goes through one `copyLink` in `HeapDumpExplorer`, for\nthe same reason opening goes through one `open`: five of them would\ndrift. And the menu item is called \"Copy link\" everywhere rather than\nnaming its surface, so that the same item doesn\u0027t read as five.\n\nCo-Authored-By: Claude Opus 5 \u003cnoreply@anthropic.com\u003e\n"
    },
    {
      "commit": "e17259c5a3f6b89359cd9fd94af1da9c21f52051",
      "tree": "5878068cb6d7f990ca94cfc8b5929bddea3bba78",
      "parents": [
        "ba793c1ea35ad298b29e818544ce49c20f141bbf"
      ],
      "author": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Sun Aug 09 15:14:27 2026"
      },
      "committer": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Sun Aug 09 15:14:27 2026"
      },
      "message": "Copy a shark:// link to a tab, and follow one back to it\n\nAnything on screen can now be handed to someone else as one line of text\nthat puts them in front of it: right click a tab, \"Copy link to this\ntab\", and clicking the URL brings the app forward and opens that place in\na new tab. Which is also how a script or an agent that has read a heap\ndump can point at what it found, rather than describing the way there.\n\nEvery Place has a spelling, and it carries the whole place rather than a\nshorthand for it — a filtered object list arrives filtered, a page of\nleaks arrives with the same groups unfolded — so that any state the app\ncan be in is reachable through a link.\n\nA link names a window, not a heap dump. The same dump open twice is what\ncomparing two of them is, so a path would be ambiguous exactly when it\nmatters, and a window id also settles what a link means once the window\nis gone: nothing, which is an empty window saying so rather than the same\nobject in whichever other window had that file open.\n\nmacOS is told at build time, through CFBundleURLTypes, and hands the URL\nto the running process. Windows and Linux have nothing like that in the\npackaging, so the app registers itself on first run and the OS delivers a\nlink by starting a process with it — which then hands it to the run that\nhas the window, over a loopback port each run publishes. Deliberately not\nsingle instance: several explorers open at once is how this app is used.\n\nOnly macOS could be tried here, so the other two are best effort. The\nwhole of the URL is unit tested either way, and the module\u0027s AGENTS.md\nrecords the trap that cost the most: a JVM launched from a shell script\nregisters as net.java.openjdk.java whatever its wrapper says, so no link\never reaches a run from Gradle.\n\nCo-Authored-By: Claude Opus 5 \u003cnoreply@anthropic.com\u003e\n"
    },
    {
      "commit": "ba793c1ea35ad298b29e818544ce49c20f141bbf",
      "tree": "ce05a432fc781f012bbb8527ac866c089242ca64",
      "parents": [
        "9808ef1bf53a8141cb76dac86b83121a95739327",
        "8b6feda53249af1bf13b1f12321800ce91611eca"
      ],
      "author": {
        "name": "py - Pierre Yves Ricau",
        "email": "pyricau@users.noreply.github.com",
        "time": "Thu Aug 06 05:34:36 2026"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Thu Aug 06 05:34:36 2026"
      },
      "message": "Merge pull request #2948 from square/when-i-open-up-a-tab-in-shark\n\nName a tab as it opens, let it grow in, and wrap the strip"
    },
    {
      "commit": "8b6feda53249af1bf13b1f12321800ce91611eca",
      "tree": "6bdc712b85749de5f06d0cdedab63c63ac841602",
      "parents": [
        "357354b0614c9178b6cd0da51a82f68df38efb9a"
      ],
      "author": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Thu Aug 06 05:24:19 2026"
      },
      "committer": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Thu Aug 06 05:24:19 2026"
      },
      "message": "Wrap the tab strip onto another line rather than scrolling it\n\nThe strip scrolled sideways once the tabs stopped fitting, which put a tab\nsomewhere you had to go looking for it — and a tab you go looking for is\none you may as well not have opened. It now wraps: a tab that no longer\nfits across the window starts a line of its own.\n\nWhat that costs is the view\u0027s height, and only for someone who has opened\nenough tabs to be reading across them anyway, which is the trade the other\nway round from a strip that hides them.\n\nCo-Authored-By: Claude Opus 5 \u003cnoreply@anthropic.com\u003e\n"
    },
    {
      "commit": "357354b0614c9178b6cd0da51a82f68df38efb9a",
      "tree": "9c9b01f58239868ba1a65d39a5583211cc8877c0",
      "parents": [
        "5e47d971c9bcc18bdd3ec2975ac01ca0812ff17d"
      ],
      "author": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Thu Aug 06 05:12:46 2026"
      },
      "committer": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Thu Aug 06 05:12:46 2026"
      },
      "message": "Name a tab as it opens, and let it grow into the strip\n\nA tab opened on an object was named by a read of the heap dump, so it\nappeared under a \"…\" placeholder and took its title, and its width, a\nbeat later. That read also queued behind the layout the same click asks\nfor, which is the larger by orders of magnitude, so on a real dump the\nplaceholder stayed up for as long as laying the tree out took. What that\nlooks like is the new tab flickering.\n\nA cell was already labelled when the view was laid out, and every cell of\nevery view is one a click opens a tab at, so the window now names the\nplace from the view the click came from — no read at all, and the tab is\nborn with its title. `List\u003cPresentedCell\u003c*\u003e\u003e.titleOf` is that answer, and\n`HeapExplorerTest` pins that it agrees with the one that reads the heap\ndump, since two ways of naming a place that drift are a tab that renames\nitself the moment anything else asks.\n\nFor the places no view drew — a row of a list, a step of the chain — the\nread stays, and it now goes in ahead of the one that lays the view out\nrather than behind it.\n\nTabs also grow into the strip the way a browser\u0027s do, over 150 ms from the\nedge they were inserted at. A tab opened in the background is one nothing\nelse on screen announces, and the strip moving over to make room is what\nsays the middle click did anything. Closing stays instant, deliberately:\na strip that holds a closed tab open for a moment is one where the tab\nunder the pointer isn\u0027t the tab a second click closes.\n\nCo-Authored-By: Claude Opus 5 \u003cnoreply@anthropic.com\u003e\n"
    },
    {
      "commit": "9808ef1bf53a8141cb76dac86b83121a95739327",
      "tree": "08c205ee3f24e287163ecc7884d6d652b4b6219d",
      "parents": [
        "5e47d971c9bcc18bdd3ec2975ac01ca0812ff17d",
        "e521d41aeea7e9c5c41b21a839a01dbe43cb8ec6"
      ],
      "author": {
        "name": "py - Pierre Yves Ricau",
        "email": "pyricau@users.noreply.github.com",
        "time": "Thu Aug 06 04:55:33 2026"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Thu Aug 06 04:55:33 2026"
      },
      "message": "Merge pull request #2944 from square/read-a-compose-ui-as-a-ui\n\nRead a Compose UI as a UI, and what it remembers as the UI\u0027s"
    },
    {
      "commit": "5e47d971c9bcc18bdd3ec2975ac01ca0812ff17d",
      "tree": "45c858f66a5dd842316e42ee07e2c4e03e19b776",
      "parents": [
        "f89c7e1f49962c0c0ac84c3edb8846f66ceb5ece",
        "b6e603b9753232760bf35430ac2b5727b2408e89"
      ],
      "author": {
        "name": "py - Pierre Yves Ricau",
        "email": "pyricau@users.noreply.github.com",
        "time": "Thu Aug 06 04:52:32 2026"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Thu Aug 06 04:52:32 2026"
      },
      "message": "Merge pull request #2947 from square/i-want-to-rethink-navigation-i\n\nMake an object a place a tab goes to"
    },
    {
      "commit": "f89c7e1f49962c0c0ac84c3edb8846f66ceb5ece",
      "tree": "cb7b737b1a69b7673a8a8387806256a03eab4a13",
      "parents": [
        "4088120ef4e0c4e67576f0291590577fa69e77d4",
        "650689a366e7c830fde03b8656c7b5eb9e377a3f"
      ],
      "author": {
        "name": "py - Pierre Yves Ricau",
        "email": "pyricau@users.noreply.github.com",
        "time": "Thu Aug 06 04:46:52 2026"
      },
      "committer": {
        "name": "GitHub",
        "email": "noreply@github.com",
        "time": "Thu Aug 06 04:46:52 2026"
      },
      "message": "Merge pull request #2939 from square/create-ownership-rule-that-bit\n\nRead an evicting cache as an evicting cache, for the caches an app really has"
    },
    {
      "commit": "b6e603b9753232760bf35430ac2b5727b2408e89",
      "tree": "747b48e942c1a51e13e20ca4aea421821d80d51e",
      "parents": [
        "4088120ef4e0c4e67576f0291590577fa69e77d4"
      ],
      "author": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Thu Aug 06 04:08:23 2026"
      },
      "committer": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Thu Aug 06 04:08:23 2026"
      },
      "message": "Make an object a place a tab goes to\n\nThe window kept where it was in two coordinates — which screen it was on,\nand a path the treemap had zoomed along — set from a dozen places. A click\non a rectangle, a row of a list, a field in the details panel and a step of\nthe chain each set them slightly differently, so which panes agreed with\neach other depended on which of the four had been used.\n\nNow a tab is one `Place`: an object, the pile of smaller objects left out of\none, the object list with its filter, the leaks, the starred. The map is laid\nout at the object, the chain pane walks up to a GC root from it, the details\npanel summarises it, the tab is named after it. `Place.of(cell)` for a\nrectangle and `Place.Object(id)` everywhere else, through one `open`, so\nevery way to an object is the same move.\n\nThe stored path could go because it was never independent: in a dominator\ntree the way down to an object is unique, so the map root is a function of\nthe object. `TreemapNavigation` is gone with it, and the map is rooted at\nthe object clicked rather than zoomed along a chain to it — an object that\ndominates nothing is one rectangle of its own bytes, which is the honest\nanswer to what it holds.\n\nWhich tab a click means is the click\u0027s own answer, in `OpenIn`: a plain\nclick moves the tab being read, a middle click, a ⌘/Ctrl click or \"Open in\na new tab\" opens one behind it, and the buttons on the bar always open one\nin front. Every tab closes, the last one included, which leaves the heap\ndump read and the bar one click from a tab again. Each of the three panes\nfolds away and the outer two drag wider.\n"
    },
    {
      "commit": "e521d41aeea7e9c5c41b21a839a01dbe43cb8ec6",
      "tree": "bac30eccf10da2b7e72f1b157926a7bc9b46ea63",
      "parents": [
        "d7fee0e367f64eaeca7ea5ae156c600155cdd440"
      ],
      "author": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Wed Aug 05 16:32:35 2026"
      },
      "committer": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Thu Aug 06 03:47:48 2026"
      },
      "message": "Name the app the Compose numbers were measured on\n\n`leakcanary-app`, not `leakcanary-android-sample`: the sample app has no Compose\nin it, so the note sent anyone reproducing this to an app where none of it fires.\nSay too that the images came from a screen added for the measurement and deleted\nagain, since a dump of that app as it stands has the hierarchy and not the images.\n"
    },
    {
      "commit": "d7fee0e367f64eaeca7ea5ae156c600155cdd440",
      "tree": "701afeec8f419fabc2dbf29f3a371a41861b5dce",
      "parents": [
        "fc6e8e2f41fcf4732589fb81b6149d38a8cf5664"
      ],
      "author": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Wed Aug 05 08:51:45 2026"
      },
      "committer": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Thu Aug 06 03:47:48 2026"
      },
      "message": "Read what a composable remembers as the node\u0027s, not the composition\u0027s\n\nA SlotTable is one Object[] per window holding every remember of every\ncomposable in it, every composition local map, every lambda and every LayoutNode\nside by side, plus an int[] describing it. Read as it is stored, a composition is\na flat list of thousands of unrelated objects: an image one screen remembers is\nheld by the same array as an image five screens away, and the answer to \"what is\nholding this bitmap\" is a composition rather than a piece of UI.\n\nThe int[] is what makes the shape recoverable — five ints a group in the order\nthe composables ran, each group saying how many groups it contains, where its\nslots start, and whether it emitted a node — so this walks it and hands each\nslot out from the node whose composable is inside. A table it can\u0027t make sense of\nkeeps every reference of its own and says so in the log, since half a table read\nwould leave objects reachable through nothing.\n\nUnlike every other reader here this replaces the array\u0027s references rather than\nadding to them, which is the thing to know before changing it: the array sits\nunder the composition, nowhere near the UI, so an added reference would push a\nbitmap further up rather than down. The array is still a node holding its own\nbytes and every element is still reached exactly once.\n\nMeasured with the commit before it, on an API 36 dump of the sample app:\nAndroidComposeView retains 10.4 MB where it retained 2.6 MB, its root LayoutNode\n7.7 MB where it retained 8.9 kB, and the BitmapPainters and AndroidImageBitmaps\nof a screen are now drawn under the composables that show them. The numbers, and\nwhat is still flat under the root, are in notes/dominator-tree.md.\n"
    },
    {
      "commit": "fc6e8e2f41fcf4732589fb81b6149d38a8cf5664",
      "tree": "b4fdb5d36eb58b485441ffcc2b0e5c242d68c81e",
      "parents": [
        "650689a366e7c830fde03b8656c7b5eb9e377a3f"
      ],
      "author": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Wed Aug 05 08:50:22 2026"
      },
      "committer": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Thu Aug 06 03:47:48 2026"
      },
      "message": "Hold a Compose UI\u0027s nodes and modifiers where they belong\n\nA Compose UI is a tree of LayoutNodes, but nothing in a heap dump reads it as\none. A node keeps its children behind a vector with a mutation callback around\nit, so every two levels of UI have three objects of bookkeeping between them and\nit\u0027s an array that gets to own each node. And the rivals are worse than a\nview\u0027s: AndroidComposeView keeps a flat registry of every node of a window by\nsemantics id, so a screen\u0027s whole node tree hangs off the view as a list, and\nCompose\u0027s modifier nodes point back at their coordinators, their layers and each\nother, so one reference into any of them reaches the lot — measured on an API 36\ndump, three from outside, an input method manager\u0027s focus listener, the snapshot\nobserver\u0027s static, and the Recomposer.\n\nSo the same two things a view hierarchy already gets: a virtual reference from a\nparent node to each child, and rules saying that a node belongs to its parent, a\nwindow\u0027s top node to the view hosting it, and a modifier to the chain it is on.\nOn a real dump that\u0027s a Compose screen\u0027s bytes drawn inside the hierarchy of the\nscreen showing it rather than at the top of the heap.\n"
    },
    {
      "commit": "650689a366e7c830fde03b8656c7b5eb9e377a3f",
      "tree": "cb7b737b1a69b7673a8a8387806256a03eab4a13",
      "parents": [
        "7a18ce988c02501bc9dd78862b08a662ca367819"
      ],
      "author": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Thu Aug 06 03:47:40 2026"
      },
      "committer": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Thu Aug 06 03:47:40 2026"
      },
      "message": "Say in the notes which reader the cache walk is now\n\nThe cache section still described a walk `CachedMapValues` no longer makes itself.\n"
    },
    {
      "commit": "7a18ce988c02501bc9dd78862b08a662ca367819",
      "tree": "e43b3a44e88863ddc265c79fe4e11bbcd785bb70",
      "parents": [
        "8d44b78d7168b53eaf46ad87ee87ef377cad23a8"
      ],
      "author": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Thu Aug 06 03:45:09 2026"
      },
      "committer": {
        "name": "Pierre-Yves Ricau",
        "email": "py@squareup.com",
        "time": "Thu Aug 06 03:45:09 2026"
      },
      "message": "Read a cache\u0027s map with Shark\u0027s reader rather than by field name\n\n`CachedMapValues` walked the map of each cache itself: read a field called\n`table`, follow `next` down each bucket, take `value` off every node, matching\nnames against the whole instance because which class declares them differs\nbetween runtimes. Shark answers that question already, so this asks it instead.\n\nWhat goes with the hand-rolled walk is the guard against a bucket chain that\nloops, which a heap dump being a file can say. Nothing is lost: the explorer\nwalks every map of the dump through Shark\u0027s own reader to build the tree in the\nfirst place, so a dump that would loop here loops there first.\n"
    }
  ],
  "next": "8d44b78d7168b53eaf46ad87ee87ef377cad23a8"
}
