Releases: itswl/agent-session-query
Release list
v0.13.0
Session directories outside the default home
The file-backed sources — pi, claude, codex and gemini — scanned one hard-wired directory
under home, so sessions that lived anywhere else were invisible: an external disk, a
backup, or the agent containers whose sessions land on a bind mount on the host. --path
reads them now, in two forms:
--path claude=/mnt/disk/.claude/projects relocate the source
--path claude:box2=/mnt/box2/.claude/projects add an instance
A relocation points the existing source at another directory. A labeled instance joins
the list beside whatever else is enabled, named claude:box2 in the startup banner, the
source field, the page's source filter and /projects — and it prefixes its label onto
the project and cwd, so sessions from several machines that share one working directory
stay separate projects instead of collapsing into one.
Labeled instances are an explicit request, so they are enabled in every mode, even when
the directory does not exist yet (the startup log warns about it). Finding a session by
the bare mode name still reaches into labeled instances, so an MCP client holding
"claude" keeps working. The json-map and SQLite sources keep their layout across several
files and reject --path rather than half-supporting it.
Also
The page's source tags render a neutral dot for a source they do not know, so a labeled
instance reads as a source and not as a status.
Full changelog: v0.12.0...v0.13.0
v0.12.0
The page, redesigned
The bases are black and white now — a black canvas in the dark theme, a white one in the
light, hue-less greys between. The hues are the page's original night palette, spent only
where colour means something: your turns and the selected row, a session's state, a tool
block's category, which CLI a session came from. Everything else got quieter — sections
instead of stacked cards, rows that read as surfaces rather than table lines.
Light and dark, with a switch
The page still follows the system until the half-disc button on the header pins a theme.
The choice is stored, applied before the first paint (no flash of the other theme on
load), and handed back to the system the moment your choice agrees with it again — a
pinned theme never outlives its reason.
The type
Geist and Geist Mono (SIL OFL, licence text alongside) are embedded and served from
/ui/fonts, so the Content-Security-Policy still allows no other origin and the page still
makes no request anywhere else. CJK text falls through to the system fonts named in the
stack. The binary grows by about 115 KB.
Panes that carry their own controls
Each side pane has a bar of its own: "Collapse all" sits over the project groups it
collapses, and the fold toggle sits at the edge the pane folds towards. A folded pane
keeps a slim rail with the toggle pointing back instead of vanishing, and both remember
what you chose. Project headers read as folded or open at a glance — chevron, weight and
count chip change with the state.
Smaller things
Loading a conversation shows a skeleton that holds back long enough never to flash, a
freshly opened one eases its messages in, the auto-refresh checkbox became a switch,
numbers are tabular throughout, empty states say what to do rather than where things used
to be, and the icons are regenerated from the same generator with the new colours. On a
phone the header no longer clips the search row while the Sessions pane is up.
Full changelog: v0.11.6...v0.12.0
v0.11.6
A subagent's session is not its parent's
Codex forks a subagent's rollout from the session that started it, and the file's first
session_meta is that fork record: session_id there is the parent's id, and the file's own
is in id — the same UUID that ends the filename. The list read session_id first, so every
subagent was reported as its parent, and several files came back under one sessionId.
Measured across every rollout on a real install: on the seven subagent files id equals the
UUID in the filename and session_id equals the parent's, while the nine ordinary ones carry
the same value in both fields.
An id is what everything else names a session by, so this was not only a listing detail — the
reader opened the wrong session for a duplicate row, and /sessions/<id> had nothing to choose
between them. A subagent is listed under its own id now; a forked meta that carries no id
falls back to the filename, and only a file with no metadata row at all uses a salvaged id.
The rows that would not go away
Switching the page's source filter to claude left codex rows behind — 48 rows where 27 were
expected, with those same shared ids.
The list is patched by sessionId rather than rebuilt, so a poll updates text in place instead
of throwing away what you are reading. The index that patch uses holds one node per id, and with
two rows sharing an id the earlier node was in neither the next render's rows nor the sweep that
clears what is no longer listed: nothing ever removed it, and it outlived every filter that
should have dropped it.
A repeated id now drops the earlier node as the index is built. The duplicates themselves are
gone with the fix above; this is what kept them on screen for as long as they lasted.
Full changelog: v0.11.5...v0.11.6
v0.11.5
Hermes sessions that were not there
Every Hermes session was missing from the list on a current install, while the database held
them. The SQLite list query filtered on WHERE s.hidden = 0, and hidden is a column the
sessions table no longer has: newer Hermes marks the sessions it hides in archived, and
filters on COALESCE(archived, 0) itself. SQLite fails the whole statement on an unknown
column — it does not degrade, it stops — so the query returned nothing and the list came back
empty.
The column is read off the schema now (pragma_table_info) and the filter is built from
whichever one is present, hidden or archived. With neither, the list runs unfiltered:
showing one session Hermes would have hidden beats reporting a full database as empty.
Listing, messages and the final result all read normally again, on both column names.
An empty list that is not an empty source
Part of why this went unseen is that it did not look like a bug. A source that could not be
read answered with zero records, exactly like a source with nothing in it, and the only trace
was a [WARN] on stderr — so "why is my Hermes history gone" and "you have no Hermes history"
came back as the same answer.
A source says so now. /sessions, /health and the MCP list_sessions tool carry
warnings: [{source, error}] when one could not be read, and nothing at all when they are all
fine. /health is unauthenticated, so it reports what the last scan hit rather than starting
one, and the list ETag folds the warnings in — a source going from readable-and-empty to
unreadable changes no record at all, and a 304 would otherwise hide the one thing that did
change.
Full changelog: v0.11.4...v0.11.5
v0.11.4
v0.11.4
Read-only Agent memory preview:
- project timeline UI
- read-only MCP project activity and memory search
- multi-term session aggregation
- layout overflow hardening
v0.11.3
GET / now says what this service can do
The root endpoint is what a caller reads to find out what is here — a shell script, or an
agent that found the port and nothing else. It listed six routes while thirteen were served:
/ui, /search, /projects, /export and /mcp were all reachable but undiscoverable
from the one place that exists to describe them.
It lists all thirteen now, and a new auth field states that a token is required everywhere
except the lines marked (no token). endpoints is still an array of strings, so anything
parsing it keeps working.
A test holds the list to the router
The list had drifted because it was a hand-written literal inside the handler — the same way
the MCP tool descriptions had drifted, and it gets the same treatment. It moves into a table,
and TestRootListsEveryRoute pins both sides:
- every path literal in
http.goandui.gomust be advertised, or named with a reason for
staying out. Routing here is a chain of path comparisons rather than a table, so those
literals are the only list of routes that exists — reading them out of the source means a
route added without a line in the list fails the build rather than going unnoticed. - every advertised line must answer, and must need a token exactly when it does not say
(no token).
The first version of that second check passed while the list advertised a route that does not
exist: an unknown path answers 401 to an anonymous request, because the auth gate runs
before the 404, so "gated" and "missing" look identical until a token is sent. It asks both
ways now. Verified by breaking it four ways — an unlisted route, a listed route that does not
exist, a public endpoint marked private, and a route listed with the wrong method. All four
fail the test.
Full changelog: v0.11.2...v0.11.3
v0.11.2
The copy button is gone
The session card had a button that copied the session id. A bare id is not what leaves the
page: pasting a session somewhere — a chat, an issue, another agent — means pasting the URL,
and that URL is already in the address bar, which selecting a session has been updating all
along. The id is still readable in the file path the card shows.
The cost is stated rather than discovered later: installed as an app there is no address
bar, so on a phone a session link can no longer be taken from the interface — it has to come
from a browser tab.
The card head is now the heading, the format picker and Export.
READMEs
Both now describe what the card actually offers — the file path, which is what you paste
into a terminal, and the export — in place of a one-click copy of the sessionId that no
longer exists.
Full changelog: v0.11.1...v0.11.2
v0.11.1
The export downloaded the wrong extension
The page builds its download from a blob, so it never reads the
Content-Disposition the server sends — and it had .md hardcoded. Every format arrived
named Markdown: JSONL saved as .md, HTML saved as .md. The extension follows the
selected format now, and the filename goes through the same sanitising the server applies,
so a title containing a slash no longer becomes a path.
The Export button also stopped fitting. Naming the format in the button was meant to make
the picker beside it self-explanatory, but two controls each naming the format is one too
many for a 290px pane — the label ran 34px past the card's edge. The picker says it; the
button says Export.
A Claude Code skill ships with the repository
.claude/skills/agent-sessions/SKILL.md carries
the part a tool schema cannot: which tool answers which question, how to land on a search
hit instead of paging towards it, when the answer is a file rather than context, and what to
warn about when handing a pack to another agent.
ln -s "$PWD/.claude/skills/agent-sessions" ~/.claude/skills/agent-sessionsCode scanning
CodeQL now runs over the Go sources on every push. (Thanks — it caught nothing yet, which is
the outcome a read-only service should produce.)
Documentation
docs/mcp.md had get_messages orphaned outside the tools table by an earlier edit — the
table listed four tools and the fifth appeared as a stray paragraph at the bottom of the
file. A check now compares the documented tool names against what tools/list actually
returns. The document also gained a section on how the tools fit together, since the table
says what each one is but not which to reach for.
Full changelog: v0.11.0...v0.11.1
v0.11.0
Handing a stretch of work to something that was not there for it
Three additions that all answer the same question in different sizes, plus a fix to the
export that made it trustworthy.
A pack of several sessions, as one document
GET /export?project=&since=&until=&source= assembles several sessions into one document,
oldest first. Each entry is what was asked and what that session concluded, with the id and
time to check it against:
## 1 · 2026-09-14 08:51 · claude · a1b2c3d4
- **Project**: /repos/api-gateway · **Messages**: 1281
- **Asked**: add per-route rate limits to the gateway
- **Concluded**: done — the limits are live behind a feature flag,
config in `gateway/ratelimit.yaml`…
- **Transcript**: `/sessions/a1b2c3d4…/export?format=jsonl`
## 2 · 2026-09-15 11:55 · claude · e5f6a7b8
- **Project**: /repos/api-gateway · **Messages**: 1842
- **Asked**: the limits are too tight · loosen them to 200 rps for the read paths
- **Concluded**: adjusted and deployed; the dashboard shows no throttling…
- **Transcript**: `/sessions/e5f6a7b8…/export?format=jsonl`
Neither half of that pair is generated. The opening ask is the session's display name and
the outcome is the final result every source already computes, so a pack is deterministic
and replayable — the property a written summary lacks, since a summary decides what matters
before anyone knows what will be asked of it.
Measured on a real week: 20 sessions, 28 677 messages, six sources → 14 KB. Small
enough to hand over whole. mode=full inlines the transcripts and is off by default,
because that is the form that can be enormous.
A short opening is extended with the turns that followed — "参考评审一下" says nothing about
what is being reviewed — up to three messages or 200 characters.
What a pack does not do is say what is still true. Two sessions a week apart can
conclude opposite things and the reader has to see both, in order; the header says so
rather than the document pretending to be current.
The export covers the session, and says what it covers
Export used to inherit the message stream's page size of 200, so exporting a
16 000-message session produced its last 200 with nothing in the file to say so — it read
exactly like an export of the whole thing, and it was 1.2% of it. No limit now means
the whole session, and whatever it holds, the file states:
- **Messages**: all 16 561 messages
- **Messages**: 200 of 16 561 messages (the latest 200; the rest is not in this file)
Four export formats
format |
Served as | For |
|---|---|---|
md (default) |
text/markdown |
reading, pasting |
jsonl |
application/x-ndjson |
one tagged object per line — a jq query is a filter, not a parse |
json |
application/json |
the same data as one document |
html |
text/html |
a page that stands on its own: inline stylesheet, no scripts, no requests |
The format is now a picker on the session card as well, so none of them needs curl.
Search can be scoped, and filtered by role
search_sessions (and /search) take pattern to search inside one session and role to
keep only user or assistant hits. Without the first, "where in this session did we
discuss X" meant paging get_messages: on the largest session here that is 332 requests at
the default limit, against 1. The role filter is applied while collecting rather than to the
finished slice, so per_session counts hits that match instead of hits that came first.
Fixed
get_messagesaccepts anatargument it never declared, so no client could discover
it — the whole point ofatis landing on a search hit. A test now compares each tool's
declared properties against the arguments its implementation reads.search_sessionsstill listed six sources after the seventh was added; the list is
derived from the source registry now.- The collapsible chrome stopped bouncing at the edges of the scroll.
Full changelog: v0.10.3...v0.11.0
v0.10.3
Install it as a web app
Open /ui on a phone and Add to Home Screen gives it an icon, a name, and a window
without browser chrome. That needs a manifest, the icons it names, and — because iOS
ignores manifests — the apple-* tags as well.
Installed, the page runs edge to edge, so the mobile rules now pad by
env(safe-area-inset-*). The notch and the home indicator would otherwise sit on top of
the header and the last row of a list. The padding goes on the bars and on the scrollable
content rather than on the container, since insetting the container would leave the
letterbox bars plainly visible.
The icons are drawn, not exported
go run tools/icongen.go renders them with nothing but the Go standard library, so the
same source produces the same bytes on any machine and the repository needs no image
toolchain. The mark is three bars of decreasing width on a dark rounded square, in the
page's own accent colours — a list of sessions, legible down to a browser tab.
| File | What reads it |
|---|---|
icon-192.png / icon-512.png |
launchers (purpose: any) |
icon-maskable-512.png |
Android, which crops the icon to its own shape — the background fills the square and the glyph stays inside the safe zone |
apple-touch-icon.png |
iOS home screen |
favicon.ico |
the browser tab (the same drawing in an ICO container, replacing the old placeholder) |
Unchanged behaviour
Nothing about the API, the data sources or the desktop layout moves in this release. The
page still serves one embedded binary; the icons and the manifest are embedded the same
way the stylesheet is.
A test fetches the manifest and every icon it names, and checks the page references them —
a file the generator wrote but go:embed did not pick up should fail in CI rather than on
someone's phone.
Full changelog: v0.10.2...v0.10.3