crowsnest sessions view
one row per agent session, folded from the sighting stream

Table of Contents

Status: DRAFT. Spec only. The locked wire contract is crowsnest v2.4; this proposes an additive v2.5 read endpoint, 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.

  1. The ring is short. 100 sightings covered 70 seconds with three sessions active. A session's SessionStart is gone from GET /sightings roughly a minute after it happens. No fold over the snapshot can report "started".
  2. Timestamps tie. start has one-second resolution on this box, and the hook POSTs from a backgrounded curl, so arrival order is not event order and two events in the same second are common.
  3. Delivery duplicates. In www.wal.sh sessions every PreToolUse arrives twice with the same attrs.tool_use (6 of 6 in the ring; racketcon-2026, 14 of 14 once). Cause: the project .claude/settings.json and the global ~/.claude/settings.json both register a crowsnest hook. The fold must be idempotent; fixing the double registration is a separate change.
  4. 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 / SubagentStop counts do not balance inside the ring (2 vs 6), so subagent depth cannot be tracked from the ring.
  5. Not every event carries every attr. mode and effort are absent on MessageDisplay, InstructionsLoaded, SubagentStart and Notification. 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"];
}

sessions-state.png

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 /sightings changes nothing in GET /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_seen reset, started nil and counts from zero. Its state is whatever its first post-restart event implies; an idle session stays invisible until it does something. /info gains uptime_s context already; the view shows "since receiver start" when first_seen is within a minute of boot.
  • Silence. An idle session emits nothing, so silence cannot mean ended: claude agents --json listed 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

  1. Fold in the receiver (proposed) versus viewer-only over SSE.
  2. Session TTL: 24 h default against Claude Code listing five-day idle sessions.
  3. Sort: attention-first (proposed, iTerm2) versus started-time (Claude Code).
  4. Short id: last 6 (proposed, matches cc- lanes) versus first 8 (Claude Code id).
  5. DELETE /sightings clears the session table too (proposed) or only the ring.
  6. Stale threshold 300 s.
  7. Which optional hook keys to add first; tmux and notification are the cheapest and close the most.
  8. Fix the double hook registration in www.wal.sh, or leave dedupe to the fold only.
  9. GET /sightings?run= documented but not implemented: implement or strike the sentence.
  10. First snapshot reporter: a claude agents --json poller (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