crowsnest sessions view
one row per agent session, folded from the sighting stream
Table of Contents
GET /sessions, and a sessions view in the dashboard.
1. Scope
The dashboard today renders the log: one row per sighting, one sighting per Claude Code hook event. With three sessions active, the log turns over in about a minute and says nothing about which sessions exist. The sessions view adds a second, coarser projection: one row per session, the way Claude Code's agent list, iTerm2's Session Status tool and Herdr show running agents.
babashka-workshop-spectre-4a [428a51] · interactive · busy · started 5d ago skills-7e [aa454f] · interactive · idle · tmux skills:@8.%8 · started 4d ago order-optics-operator [5cde02] · interactive · idle · tmux order-optics:@0.%0 · started 4d ago
This is a small addition, not a redesign. The log stays the core view: the flow of every hook call, unchanged. The sessions dashboard sits beside it as a second tab and is built from the same ingest.
That is the target. The receiver can reach part of it from what the hooks send today; 6 says which part, and 7 says what the rest costs.
| role | change |
|---|---|
| reporter (hook) | none required; optional additions in 7 |
| receiver | v2.5: incremental session table, GET /sessions, capability sessions |
| dashboard | a Sessions tab beside the log |
| oracle (v3) | a second fold, fold-sessions, judged by the same predicate |
2. What the stream actually carries
The fold is built on what arrives, not on what the hook documentation says arrives. Read on 2026-10-06 from the live receiver (GET only):
curl -s http://127.0.0.1:8127/info
curl -s http://127.0.0.1:8127/sightings | jq -r '.[].state' | sort | uniq -c
/info version 2.4.0, uptime_s 211, sightings 100, retention.cap 100,
capabilities [state, typed-attrs, correlation, clear]
26 PreToolUse 28 PostToolUse 11 PostToolBatch
22 MessageDisplay 6 SubagentStop 2 SubagentStart
2 Stop 2 InstructionsLoaded 1 Notification
top-level keys : after attrs duration id name run service start state status
attrs keys : effort file host message mode origin sha source tool tool_use turn
runs in ring : 3 (racketcon-2026 35, www.wal.sh 64, guile-sicp 1)
start span : 1791291050000 .. 1791291120000 (70 s for all 100 sightings)
start % 1000 : always 0 (macOS date has no %3N; whole-second resolution)
status : ok on all 100
Five properties of that stream decide the design.
- The ring is short. 100 sightings covered 70 seconds with three sessions
active. A session's
SessionStartis gone fromGET /sightingsroughly a minute after it happens. No fold over the snapshot can report "started". - Timestamps tie.
starthas one-second resolution on this box, and the hook POSTs from a backgroundedcurl, so arrival order is not event order and two events in the same second are common. - Delivery duplicates. In www.wal.sh sessions every
PreToolUsearrives twice with the sameattrs.tool_use(6 of 6 in the ring; racketcon-2026, 14 of 14 once). Cause: the project.claude/settings.jsonand the global~/.claude/settings.jsonboth register a crowsnest hook. The fold must be idempotent; fixing the double registration is a separate change. - Subagents share the parent's run. Tool calls made by a subagent arrive with
the parent session's
run(this spec's own research calls appear under the parent's run).SubagentStart/SubagentStopcounts do not balance inside the ring (2 vs 6), so subagent depth cannot be tracked from the ring. - Not every event carries every attr.
modeandeffortare absent onMessageDisplay,InstructionsLoaded,SubagentStartandNotification. A session record keeps the last non-empty value per attr rather than the last value.
run is the join key to Claude Code's own list: the sessionId values from
claude agents --json on the same box matched the three run values in the ring.
3. The fold
3.1. Signature
fold-sessions : Sighting* -> Map<Run, Session> step : Map<Run, Session> -> Sighting -> Map<Run, Session> ; pure, total
A sighting with no run, or whose service does not start with cc-, does not
touch the table. step is total over every sighting the receiver accepts: an
unknown state updates last_seen and counts.events and nothing else.
3.2. The session record
Session = {
run : string ; key; the Claude Code session_id
service : string ; cc-<last 6 of run>, the log's lane name
origin : string|nil ; attrs.origin (cwd basename: repo or worktree dir)
host : string|nil ; attrs.host (hostname -s)
sha : string|nil ; attrs.sha at last_seen
mode, effort : string|nil ; last NON-EMPTY value seen
state : busy | waiting | idle | ended
stale : bool ; derived at read time, see Silence
first_seen : epoch ms ; min start seen by THIS receiver process
last_seen : epoch ms ; max start seen
started : epoch ms|nil ; start of an observed SessionStart, else nil
ended_at : epoch ms|nil ; start of an observed SessionEnd
last_event : string ; state of the sighting that set `state`
last_tool : string|nil ; attrs.tool of the newest Pre/PostToolUse
open_tools : int ; |PreToolUse ids without a PostToolUse*|
counts : { events, tools, errors, turns }
}
first_seen and started are different fields on purpose. first_seen is when
this receiver first heard of the session, which after a receiver restart can be
days after the session began. started is non-nil only when a SessionStart
was observed. The view never labels first_seen as "started".
Counts are deduplicated: tools counts distinct attrs.tool_use on
PostToolUse / PostToolUseFailure; errors counts sightings with
status=error (*Failure, *Denied), deduplicated on (state, tool_use, start);
turns counts Stop / StopFailure deduplicated on start; events counts
every accepted sighting and is the one count that duplicates inflate.
3.3. State machine
Four states. Each transition names the hook event, verbatim as state on the
sighting, that causes it.
| from | event (state) |
to | note |
|---|---|---|---|
| (none) | any accepted event | see row for that event; SessionStart gives idle |
first sighting creates the record |
| any but ended | UserPromptSubmit, UserPromptExpansion |
busy | a turn begins |
| any but ended | PreToolUse, PostToolUse, PostToolUseFailure, PostToolBatch, SubagentStart, PreCompact, PostCompact |
busy | activity inside a turn; subject to the tie rule |
| busy, idle | PermissionRequest, Elicitation |
waiting | the agent is blocked on a human |
| waiting | PostToolUse, PermissionDenied, ElicitationResult |
busy | the human answered; PermissionDenied also fires with no request before it (an auto-mode denial) and maps to busy from any state |
| any but ended | Stop, StopFailure |
idle | turn over; StopFailure also counts an error |
| any | SessionEnd |
ended | terminal |
| ended | SessionStart |
idle | resume under the same id reopens the record |
| any | SubagentStop, Notification, MessageDisplay,=InstructionsLoaded=, CwdChanged, FileChanged, ConfigChange, Task*, Worktree*, TeammateIdle, Setup, other |
unchanged | updates last_seen only |
SubagentStop changes nothing because it says a subagent finished, not that the
session is working. Observed 2026-10-06 across ten sessions on this box: it
arrives seconds after the parent's Stop, and while it mapped to busy every
finished session read busy until the stale window. (v2.5.1)
Notification changes nothing because the sighting does not say which kind it
is. The raw payload does: in an earlier window of the hook log on this box
(since rotated out), 130 notifications were idle_prompt and 8 were
permission_prompt. In the 11.6 h window reviewed on 2026-10-07 all 32 were
idle_prompt, each 60 to 61 s after a Stop
(session dashboards
note). The hook drops
notification_type, so the receiver cannot tell "waiting for permission" from
"idle at the prompt" by this event. PermissionRequest carries that signal
instead, and is mapped.
busy means: the most recent state-bearing event is a turn or tool event newer
than the most recent Stop. It does not mean a tool is running right now;
between tool calls inside a turn the model is generating, and that is busy too.
open_tools > 0 is the narrower "a tool is running" and is shown as a detail,
not as the state.
digraph sessions {
rankdir=LR;
bgcolor=white;
graph [fontname="Helvetica", fontsize=11, pad=0.3, nodesep=0.5, ranksep=0.7];
node [fontname="Helvetica", fontsize=10, style="rounded,filled", shape=box, penwidth=1.2];
edge [fontname="Helvetica", fontsize=9, color="#888888", fontcolor="#555555"];
idle [label="idle", fillcolor="#f3f4f6", color="#374151", fontcolor="#374151"];
busy [label="busy", fillcolor="#dcfce7", color="#15803d", fontcolor="#15803d"];
waiting [label="waiting", fillcolor="#fef3c7", color="#b45309", fontcolor="#b45309"];
ended [label="ended", fillcolor="#fee2e2", color="#b91c1c", fontcolor="#b91c1c"];
idle -> busy [label="UserPromptSubmit\nPreToolUse ..."];
busy -> idle [label="Stop / StopFailure"];
busy -> waiting [label="PermissionRequest\nElicitation"];
idle -> waiting [label="PermissionRequest"];
waiting -> busy [label="PostToolUse\nPermissionDenied\nElicitationResult"];
waiting -> idle [label="Stop"];
idle -> ended [label="SessionEnd"];
busy -> ended [label="SessionEnd"];
waiting -> ended [label="SessionEnd"];
ended -> idle [label="SessionStart"];
}
The table is authoritative; the diagram is a rendering of it. The PNG is
produced by gmake diagrams.
3.4. Ordering and the tie rule
Arrival order is not event order (property 2). The fold orders by start, then
by a fixed precedence within one second, and only then by arrival:
key(s) = (start, rank(state), arrival-seq) rank : SessionEnd 4 > Stop, StopFailure 3 > PermissionRequest, Elicitation 2 > everything else 1 state(run) = transition applied by the sighting with the greatest key among state-bearing sightings
So a PostToolUse stamped in the same second as a Stop but delivered after it
leaves the session idle, and a PreToolUse stamped one second after a Stop
makes it busy whatever order they arrive in. A sighting older than the current
state's key updates counts, first_seen, last_tool (if it is the newest tool
event) and nothing else. The precedence encodes one assumption: within a
second, a turn-ending event is the later one. A turn that ends and a new one that
starts in the same second resolves idle until the next event, which is at most
one tool call later.
Duplicates (property 3) are absorbed: open_tools is a set of tool_use ids,
not a counter; a second PreToolUse with the same id is a no-op.
3.5. Eviction, restart and silence
- Ring eviction. The session table is updated on ingest and does not depend on
the ring. Evicting a session's early sightings from
GET /sightingschanges nothing inGET /sessions. This is the reason the fold lives in the receiver (5). - Receiver restart. The table is in memory and starts empty, like the ring.
After a restart every session reappears on its next event, with
first_seenreset,startednil and counts from zero. Its state is whatever its first post-restart event implies; an idle session stays invisible until it does something./infogainsuptime_scontext already; the view shows "since receiver start" whenfirst_seenis within a minute of boot. - Silence. An idle session emits nothing, so silence cannot mean ended:
claude agents --jsonlisted idle sessions started five days earlier. Two timers apply, both evaluated at read time:
stale = state in {busy, waiting} and now - last_seen > 300 s ; shown, not a state
expiry = idle|busy|waiting: now - last_seen > CROWSNEST_SESSION_TTL_S (default 86400)
ended: now - ended_at > 600 s
table = at most 256 sessions; on overflow drop the least recent last_seen
stale exists because a busy session whose Stop was lost (a down receiver at
that moment, a killed process) would otherwise read busy forever. Five minutes
is longer than the longest tool call in the ring (one WebFetch at 11.8 s) by a
wide margin and shorter than a coffee. An expired session is removed, not
marked; it comes back on its next event.
4. Session-shaped input
Hook calls are the first input. The second is an event that already describes a
session, sent by any reporter that knows about sessions: a poller over
claude agents --json, a tmux scraper, Herdr, or a harness other than Claude
Code. It needs no new route. It is an ordinary sighting on POST /sightings:
{ "name": "session.snapshot", "service": "<reporter lane>",
"duration": 0, "status": "ok", "state": "SessionSnapshot",
"run": "<session id>", "start": <epoch ms>,
"attrs": { "origin": "skills", "title": "skills-7e", "kind": "interactive",
"activity": "idle", "tmux": "skills:@8.%8",
"started_at": <epoch ms>, "pid": 4242 } }
The fold merges it into the record keyed by run. A snapshot fills fields the
hooks cannot supply (title, kind, tmux, started, pid) and never
overrides state when hook events for that run arrived within the stale
window: hook calls say what a session is doing, snapshots say what it is. A
run seen only through snapshots takes its state from attrs.activity. Records
are producer-neutral: nothing in a snapshot is Claude-specific, so other agents
fit the same shape.
The reporter is a separate process. The receiver still never inspects processes or tmux itself (anti-goal A5).
4.1. Contract delta for spec.org (v2.5)
crowsnest is publish/subscribe over a versioned contract, with the dashboard as
one rendering of it. spec.org is that contract, and its rules
already cover this change: MINOR versions are additive, consumers feature-detect
by /info.capabilities, and MAJOR is the only compatibility gate. Hook calls
stay the primary feed; nothing here changes how a hook reports.
When the receiver implements this, spec.org gains, in the same change:
| spec.org section | addition |
|---|---|
| Versioning table | a v2.5 row: GET /sessions and the sessions capability, additive |
| Endpoints | GET /sessions, read-only, loopback-only, same CORS as /sightings |
| Attributes | reserved keys title, kind, activity, tmux, started_at (epoch ms), pid |
| Inline OpenAPI | the /sessions path and the Session schema, kept equal to the receiver by crowsnest-spec-verify |
Other implementations (the language receivers and reporters) MAY adopt v2.5 and
MUST still accept a session.snapshot sighting as an ordinary sighting if they
do not: that already holds, because name and state are free strings and the
snapshot carries every required field.
Verified on 2026-10-06 against the v2.4 receiver: a session.snapshot sighting
posted to POST /sightings returned 200 and was stored and served by
GET /sightings like any other.
5. Where the fold lives
| viewer only | receiver (GET /sessions) |
|
|---|---|---|
sees SessionStart |
only if the page was open, streaming, when it fired | yes, from receiver boot |
| survives ring turnover (70 s here) | no | yes |
| survives page reload | no | yes |
| wire change | none | additive endpoint; v2.5 MINOR |
| spec drift gate | untouched | crowsnest-spec-verify needs spec.org's inline OpenAPI updated in the same change |
other consumers (oracle, a statusline, curl) |
must re-implement the fold | read one endpoint |
| memory | browser | receiver: 256 records, bounded |
The receiver. A viewer-only fold over GET /sightings cannot satisfy the first
two rows, and those are the rows the feature is for. The viewer polls
/sightings every 2000 ms today (start-live-poll! in crowsnest.cljs; it does
not open the SSE stream, although spec.org describes the dashboard as
stream-first), so it would see only what survives between polls. A viewer fold
fed by /sightings/stream from page load is possible and needs no receiver
change, but it shows nothing about sessions that started before the tab opened,
which is all of them.
The viewer keeps a fallback: when /info.capabilities lacks sessions (a v2.4
receiver), it folds the snapshot with the same step and labels the tab
"partial: last 100 sightings". Same reducer, same code, different input; the
label is the honest difference.
/sessions/stream is deferred. Session state changes at turn granularity, and a
2 s poll of a 256-record bounded array is cheap. Adding SSE later is additive.
5.1. Endpoint
| Method | Path | Purpose |
|---|---|---|
GET |
/sessions |
the session table, ordered by 6.2; ended sessions included until they expire |
OPTIONS |
/sessions |
CORS preflight, 204, as every path |
No POST, PUT or DELETE on /sessions. DELETE /sightings (the clear
capability) clears the session table as well as the ring, so "clear" keeps
meaning "start the view over". GET / gains _links.sessions, /info gains
capability sessions and retention.sessions {cap, ttl_s}.
OpenAPI fragment, in the shape server.clj generates and spec.org inlines.
with-cors adds the CORS headers and the options operation, so they are not
repeated here.
{
"paths": {
"/sessions": {
"get": {
"summary": "Session table — one record per run, folded on ingest (v2.5, 'sessions' capability)",
"responses": {
"200": {
"description": "sessions, attention-first then most recent",
"content": {
"application/json": {
"schema": { "type": "array", "items": { "$ref": "#/components/schemas/Session" } }
}
}
}
}
}
}
},
"components": {
"schemas": {
"Session": {
"type": "object",
"required": ["run", "service", "state", "stale", "first_seen", "last_seen", "counts"],
"properties": {
"run": { "type": "string", "description": "the sightings' run (Claude Code session_id); key" },
"service": { "type": "string", "description": "cc-<last 6 of run>; the log's lane" },
"origin": { "type": ["string", "null"], "description": "attrs.origin — cwd basename" },
"host": { "type": ["string", "null"] },
"sha": { "type": ["string", "null"] },
"mode": { "type": ["string", "null"], "description": "last non-empty attrs.mode" },
"effort": { "type": ["string", "null"], "description": "last non-empty attrs.effort" },
"state": { "type": "string", "enum": ["busy", "waiting", "idle", "ended"] },
"stale": { "type": "boolean", "description": "busy|waiting with no sighting for 300 s" },
"first_seen": { "type": "integer", "description": "epoch ms; first sighting seen by this receiver process" },
"last_seen": { "type": "integer", "description": "epoch ms" },
"started": { "type": ["integer", "null"], "description": "epoch ms of an observed SessionStart; null if not observed" },
"ended_at": { "type": ["integer", "null"] },
"last_event": { "type": "string", "description": "hook event that set state" },
"last_tool": { "type": ["string", "null"] },
"open_tools": { "type": "integer" },
"counts": {
"type": "object",
"properties": {
"events": { "type": "integer" }, "tools": { "type": "integer" },
"errors": { "type": "integer" }, "turns": { "type": "integer" }
}
}
}
}
}
}
}
The endpoint lands as one change: server.clj (route, openapi
def, service-index, info, selftest fixtures) and spec.org (endpoint table,
History row v2.5, the inline /openapi.json block) move together, or
gmake crowsnest-spec-verify reports DRIFT and exits 1. That gate is the
calibration for this section: run it once with only server.clj changed and
confirm it fails before trusting its pass.
Aside, observed while reading: the run field description in the receiver's
OpenAPI says "GET /sightings?run= returns the group". The router matches on
path only; GET /sightings?run=<id> on the live receiver returned all 100
sightings. Either the filter or the sentence is wrong. Out of scope here;
recorded so the v2.5 change does not copy the claim.
6. Display
6.1. Row format
Sightings | Sessions (4) receiver v2.5.0 · up 3h12m ● www.wal.sh [07e8f6] busy Bash ×2 auto · medium macbookpro first seen 41m ago 2s ago ● racketcon-2026 [753a0c] busy WebFetch auto · medium macbookpro first seen 2h ago 4s ago ◐ wharfinger [d4e1aa] waiting Bash default macbookpro started 12m ago 38s ago ○ guile-sicp [edc5f4] idle - - macbookpro first seen 3h ago 9m ago ✕ skills [aa454f] ended Read auto macbookpro started 1h ago ended 2m ago 4 live · 1 ended · folded from 1 214 sightings since receiver start
| column | source | rule |
|---|---|---|
| glyph + state | state, stale |
● busy, ◐ waiting, ○ idle, ✕ ended; stale renders busy? / waiting? dimmed |
| origin | origin |
verbatim; falls back to host |
[xxxxxx] |
service |
last 6 of run, matching the log's cc-xxxxxx lane so a click filters the log |
| tool | last_tool, open_tools |
tool name; ×n when open_tools > 1; - when idle and none |
| mode · effort | mode, effort |
verbatim |
| host | host |
verbatim |
| age | started or first_seen |
"started X ago" only when started is non-nil, else "first seen X ago" |
| recency | last_seen, ended_at |
"X ago"; "ended X ago" for ended |
Rows are grouped by project: a heading per origin, with worktree
directories under worktrees/<name> folded into their repository when the path
says so, and a count of busy, waiting and idle sessions on the heading.
Clicking a row sets the log filter to that service, so the log answers "what
is it doing" and the sessions view answers "what exists".
6.2. Sort order
Three buckets, then a key that does not move: waiting first, busy and
idle together, ended last; within a bucket, started descending
(first_seen when no SessionStart was seen), then run. A waiting session
is the only row that asks something of the reader, so it rises. Nothing else
moves a session in the list.
v2.5.0 sorted by state, then last_seen descending (iTerm2's "sorted by
priority" choice). With ten concurrent sessions that thrashed: last_seen
changes on every hook event, so busy sessions swapped places on every 2 s poll,
and a session went from the busy block to the idle block at the end of every
turn. The dashboard rail made it worse by ordering projects by their first
appearance in this list, so whole projects jumped. From v2.5.2 the rail orders
projects alphabetically, a project with a waiting session first. Recency is
still shown, as the age on each row; it no longer drives position. Decaying
long-idle sessions out of the list is a separate, later change.
6.3. Against Claude Code's agent list
claude agents --json (Claude Code 2.1.290) returns, per session:
sessionId, name, kind, status, state, cwd, pid, startedAt, and id (first 8
of sessionId, background sessions only). Interactive sessions report
status busy / idle; background sessions report state blocked. No tmux
field is in the JSON; the tmux column in the example above comes from the
interactive list.
| field in the target row | crowsnest today | why |
|---|---|---|
display name (skills-7e) |
no | hook does not send it; raw payload has session_title only sometimes |
short id ([aa454f]) |
yes, different digits | crowsnest uses last 6 of run; Claude Code's id is the first 8. The bracketed six in the example is of unconfirmed origin |
kind (interactive) |
no | not in the hook payload at all |
| busy / idle | yes | 3.3; crowsnest adds waiting and ended |
tmux session:@win.%pane |
no | hook does not send $TMUX_PANE |
| started N ago | partly | only when the receiver saw SessionStart; otherwise "first seen" |
| repo | yes | origin (cwd basename); Claude Code shows full cwd |
| host, sha, mode, effort, last tool | yes | crowsnest has these; the agent list does not |
7. Hook additions for parity (optional)
The core view needs nothing new from the hook. These additions close the parity
gap; each is a reserved attrs key, so each is additive under v2.x and a
receiver that ignores it still conforms. The fold copies them as "last non-empty
value", like mode.
| attrs key | source in the hook | sent on | gives |
|---|---|---|---|
tmux |
tmux display -p -t "$TMUX_PANE" '#{session_name}:#{window_id}.#{pane_id}', only when $TMUX_PANE is set |
SessionStart, UserPromptSubmit |
the tmux column |
title |
.session_title from the payload |
when present | display name |
model |
.model on SessionStart |
SessionStart |
model column |
start_source |
.source (startup, resume, compact) |
SessionStart |
tells a resume from a fresh start |
end_reason |
.reason (prompt_input_exit, other observed) |
SessionEnd |
why it ended |
notification |
.notification_type (idle_prompt, permission_prompt) |
Notification |
lets Notification drive waiting / idle |
agent |
.agent_id |
tool events from subagents | subagent count and depth |
pid |
$PPID of the hook |
SessionStart |
join to claude agents --json by pid |
Kind (interactive versus background) has no source in the payload. Two routes,
neither in the hook's current contract: read claude agents --json (a process
inspection, forbidden to the receiver by A5 below, permissible to a separate
reporter that POSTs its own sightings), or an environment variable Claude Code
does not set today. Recorded as unavailable.
$TMUX_PANE is inherited: a process spawned by this Claude Code session saw
TMUX_PANE=%7, and tmux display rendered it as www-wal-sh:@7.%7, the same
shape as the target row. A hook is spawned the same way, so the value is
available to it.
8. Contracts
Goals and anti-goals in one place. Each goal names the test that proves it; each anti-goal names a test that drives the forbidden path and must see it refused. Each test names the known-bad input or implementation it must reject; a test that passes against its known-bad is not a test.
8.1. Goals
| id | goal | test | known-bad it must reject |
|---|---|---|---|
| G1 | one record per run |
POST 3 runs × 10 sightings; GET /sessions has 3 records, keys = the 3 runs |
a fold keyed on service: two runs sharing a last-6 suffix collapse to one |
| G2 | survives ring eviction | POST SessionStart for run R, then 150 sightings for run Q; /sessions[R].started non-nil, /sightings no longer holds R |
a viewer-style fold over GET /sightings reports started nil |
| G3 | order-insensitive under the tie rule | PBT: generate a session's events with seconds-resolution start, permute arrival; final state is the same for every permutation |
last-arrival-wins: deliver Stop then a same-second PostToolUse; it reports busy, must report idle |
| G4 | idempotent under duplicate delivery | POST every PreToolUse / PostToolUse twice; counts.tools and open_tools equal the single-delivery values |
a counter fold: counts.tools doubles |
| G5 | transitions match the table | table-driven fixture, one row per 3.3 row | a fold that maps Notification to waiting fails the Notification row |
| G6 | silence handled | inject a clock; busy with no event for 301 s reads stale; idle for 86 401 s is absent; idle for 5 days within a raised TTL is present and not ended |
a fold that ends on silence: an idle session flips to ended |
| G7 | contract drift gated | gmake crowsnest-spec-verify passes with the endpoint in both server.clj and spec.org |
the endpoint added to server.clj alone: the gate must print DRIFT and exit 1 |
| G8 | v2.4 fallback | point the viewer at a receiver without sessions capability; tab shows rows and the "partial" label |
a viewer that hides the tab, or shows the rows unlabeled |
8.2. Anti-goals
| id | never | test that exercises the forbidden path | known-bad it must catch |
|---|---|---|---|
| A1 | write through /sessions |
POST, PUT, DELETE /sessions → 404 NOT_FOUND; table unchanged after each |
a handler that dispatches on path only and treats DELETE as clear |
| A2 | read transcripts or any path from a sighting | POST a sighting with attrs.transcript_path and attrs.file naming a FIFO; a reader on the FIFO's other end records whether it was opened; must stay unopened. Static: no slurp, io/reader, File. on sighting values in server.clj |
an implementation that opens transcript_path to recover the session title: the FIFO open is recorded |
| A3 | egress | run the selftest under sandbox-exec with a profile that denies outbound network except loopback; must pass. Static: HttpClient appears only inside selftest |
a fold that resolves host by DNS or fetches a repo name: the sandbox denies it and the test fails |
| A4 | listen beyond loopback | connect to the box's LAN address on :8127; must be refused | start with CROWSNEST_HOST=0.0.0.0: the connect succeeds and the test must report failure |
| A5 | inspect processes or tmux | PATH shims for tmux, ps, claude that log invocations; run the selftest; the log must be empty. Static: no ProcessBuilder / Runtime.exec |
a receiver that calls claude agents --json for kind: the shim log is non-empty |
| A6 | echo content | POST a sighting with attrs.prompt and attrs.message set to a canary string; the canary must not appear in the /sessions body |
a fold that copies all of attrs into the record |
| A7 | widen CORS | GET /sessions with Origin: https://evil.example carries no Access-Control-Allow-Origin; with Origin: https://wal.sh it carries exactly that |
a route that sets * for the new path |
| A8 | unbounded memory | POST 10 000 distinct runs; table size ≤ 256 and GET /sessions answers |
a map with no cap |
9. Prior art
| system | states | how it knows | source | |
|---|---|---|---|---|
| Claude Code agent list | interactive: busy, idle; background: blocked |
its own process registry | claude agents --help and --json, Claude Code 2.1.290, run locally |
observed |
| iTerm2 3.7.2 Session Status | working, waiting, idle; "sorted by priority, elevating those requiring attention to the top" |
the cc-status hook, registered here in ~/.claude/settings.json (observed) on PreToolUse, PostToolUse, Notification, PermissionRequest, SessionStart/End, Stop, StopFailure, SubagentStop, UserPromptSubmit |
https://iterm2.com/claude-code-integration.html | opened |
| Herdr 0.9.1 | idle, working, blocked |
reads the live bottom of the pane against a detection manifest; uses the agent's own reports when an integration provides them | https://raw.githubusercontent.com/herdrdev/herdr/v0.9.3/docs/next/website/src/content/docs/agents.mdx (docs for 0.9.3; 0.9.1 installed here) | opened |
| Orca (stablyai) | not found | README mentions "Notifications and unread state" ("Know when an agent finishes or needs attention"); no state names, no detection mechanism stated | https://github.com/stablyai/orca | opened |
Three of four converge on a three-value machine with a "needs a human" state
(waiting / blocked). The waiting state here is that state. The ended state
has no counterpart because the others list only live processes; crowsnest lists
what it heard, and needs a way to say a session is gone. See also
The Agent Integration Layer, September 2026, "Session status, and what each
consumer wants from it": iTerm2 wants the three-state machine, crowsnest wants a
trace. This view is crowsnest deriving the first from the second.
10. Open decisions
- Fold in the receiver (proposed) versus viewer-only over SSE.
- Session TTL: 24 h default against Claude Code listing five-day idle sessions.
- Sort: attention-first (proposed, iTerm2) versus started-time (Claude Code).
- Short id: last 6 (proposed, matches
cc-lanes) versus first 8 (Claude Codeid). DELETE /sightingsclears the session table too (proposed) or only the ring.- Stale threshold 300 s.
- Which optional hook keys to add first;
tmuxandnotificationare the cheapest and close the most. - Fix the double hook registration in www.wal.sh, or leave dedupe to the fold only.
GET /sightings?run=documented but not implemented: implement or strike the sentence.- First snapshot reporter: a
claude agents --jsonpoller (cheapest, gives kind and title) or a tmux scraper (gives the pane).
11. Evidence ledger
| claim | basis |
|---|---|
sighting fields, attrs keys, event counts, 70 s span, whole-second start |
observed: live receiver, GET only, 2026-10-06 |
duplicate PreToolUse in www.wal.sh |
observed in the ring; cause observed in both settings files |
subagent tool calls carry the parent run |
observed: this spec's own tool calls under the parent's run |
| what the hook sends and drops | observed: ~/.claude/crowsnest-hook.sh source, and payload key sets from ~/.claude/crowsnest-hooks.jsonl (keys only; values not read except source, reason, notification_type) |
run equals Claude Code sessionId |
observed: claude agents --json on the same box |
$TMUX_PANE inherited by children of Claude Code |
observed for a Bash tool child; inferred for hook children |
| viewer polls every 2000 ms, no SSE | observed: start-live-poll! in crowsnest.cljs |
?run= filter not implemented |
observed: live receiver returned all 100 |
| iTerm2, Herdr, Orca behavior | documented: pages opened, as cited; not run |
| tie-rule precedence ("turn-ending event is the later one") | inferred; to be tested by G3 |
| 300 s stale, 24 h TTL, 256 cap | chosen, not derived |