User pushback: "транслирует в онлайне какие агенты создаются? основное
окно агента, а дальше при запусках появляются новые ветки, мы показываем
в онлайне как агенты собираются и работают"
Earlier `kei-graph-export` rendered the static SUBSTRATE (all 581 atoms,
catalog-style). User wanted the LIFECYCLE: orchestrator at center, every
new agent as a fading-in branch, every tool call as a pulse, every
completion as a fade-out. TTL = until done; pure online, no history
accumulation per user direction.
Three-layer architecture, all conforming to schema /tmp/agent-events-schema.md:
LAYER 1 — Event emitters (4 hooks)
hooks/agent-event-spawn.sh PreToolUse:Agent → agent_spawn event
hooks/agent-event-done.sh PostToolUse:Agent → agent_done event
(parses STATUS-TRUTH MARKER for outcome,
computes cost_usd from token×pricing table)
hooks/tool-use-event.sh PreToolUse:Bash|Read|Edit|Write|Grep|Glob|NotebookEdit
→ tool_use event
hooks/skill-record.sh EXTENDED — second emit step writes skill_use
event in addition to existing kei-ledger
record-skill call
All 4 are POSIX /bin/sh, defensive (never block, exit 0), bypass via
KEI_EVENTS_BYPASS=1. Append-only JSONL to
~/.claude/memory/agent-events.jsonl.
Smoke: 4 synthetic invocations cover spawn/done/tool/filter cases.
LAYER 2 — kei-graph-stream Rust daemon
_primitives/_rust/kei-graph-stream/ (~480 LOC, 5 files + 1 test)
- Tails events.jsonl every 200ms (poll-based, no notify dep).
- Parses each event, updates AliveState (insert on spawn, remove on done).
- Broadcasts {"type":"event","data":<event>} to all WebSocket clients.
- On client connect: sends {"type":"snapshot","alive":[...]} first.
- Heartbeat: {"type":"ping"} every 30s.
- axum 0.7 + ws feature (already in Cargo.lock via kei-cortex).
- Bypass: KEI_GRAPH_STREAM_BYPASS=1.
Bound to 127.0.0.1:8201 (loopback only). Endpoints:
GET /stream → WebSocket upgrade
GET /health → "kei-graph-stream alive"
4 unit + 1 integration test. cargo build clean.
Installed binary: ~/.cargo/bin/kei-graph-stream
Launchd plist: io.keisei.graph-stream (RunAtLoad, KeepAlive)
Loaded as PID 52678, /health 200 OK verified.
LAYER 3 — live-graph.html (single-file frontend)
~/Projects/lbm-graph-viz/live-graph.html (~464 LOC, self-contained)
- SVG full-viewport, dark #0f172a, CSS grid background.
- Pinned center node "main" (orchestrator), gold #fbbf24, glowing.
- Agents radiate via D3 force-simulation; color-by-model
(sonnet=green, opus=red, haiku=blue, default=gray).
- On agent_spawn: fade-in 300ms, edge from main to new node.
- On tool_use: pulse on agent node (r 8→12→8 over 400ms) +
floating tool name label fades 800ms.
- On agent_done: outcome-color flash → fade-out 800ms → remove.
- WebSocket client: ws://127.0.0.1:8201/stream, exponential-backoff
reconnect (1s→30s).
- Top-right status badge: ● connected | ○ reconnecting | ✕ disconnected.
- Bottom counters: alive / spawned / tool calls / done / last event age.
- No build step. D3 v7 from CDN. Pure HTML+JS+CSS.
End-to-end smoke (this machine, just now):
- daemon health 200 OK
- hook injected agent_spawn → daemon broadcasts → AliveState=1
- hook injected agent_done → daemon broadcasts → AliveState=0
- frontend file syntax-checked clean
What this does NOT do (deferred, by user direction "это онлайн"):
- History persistence — agents who finished are GONE from the graph.
Per-session log remains in events.jsonl + sleep-sync if user wants
to consult later, but the live view is RIGHT NOW only.
- Sub-agent attribution beyond "main" — orchestrator-direct tool calls
show on the orchestrator node. Sub-agent's internal tool calls would
need session-id correlation; current schema has agent_id="main"
placeholder for non-Agent tool calls.
- Replay mode — no time-scrubber. Possible follow-up if useful.
- Auth on WebSocket — bound to 127.0.0.1 only. Local-only by design.
=== STATUS-TRUTH MARKER ===
shipped: functional
stubs: 0
cargo-check: PASS
behaviour-verified: yes
follow-up-required:
- Sub-agent tool-call attribution (correlate session_id chain)
- Replay mode with time scrubber (if user finds use)
- Tool aggregator nodes ("Bash bucket" with N) instead of per-agent pulses
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
59 lines
2.9 KiB
Bash
Executable file
59 lines
2.9 KiB
Bash
Executable file
#!/bin/sh
|
||
# agent-event-done.sh — PostToolUse:Agent hook.
|
||
# Emits `agent_done` event to ~/.claude/memory/agent-events.jsonl
|
||
# per the locked schema at /tmp/agent-events-schema.md (2026-05-02).
|
||
# Reuses STATUS-TRUTH MARKER parsing from agent-outcome-backfill.sh.
|
||
# Defensive: never blocks, exits 0. Bypass: KEI_EVENTS_BYPASS=1.
|
||
set -u
|
||
|
||
[ "${KEI_EVENTS_BYPASS:-0}" = "1" ] && exit 0
|
||
command -v jq >/dev/null 2>&1 || exit 0
|
||
|
||
PAYLOAD=$(cat 2>/dev/null || true)
|
||
[ -n "$PAYLOAD" ] || exit 0
|
||
|
||
TOOL=$(printf '%s' "$PAYLOAD" | jq -r '.tool_name // empty' 2>/dev/null)
|
||
[ "$TOOL" = "Agent" ] || exit 0
|
||
|
||
EVENTS_FILE="$HOME/.claude/memory/agent-events.jsonl"
|
||
mkdir -p "$(dirname "$EVENTS_FILE")" 2>/dev/null || true
|
||
|
||
TOOL_USE_ID=$(printf '%s' "$PAYLOAD" | jq -r '.tool_use_id // .toolUseId // "unknown"' 2>/dev/null)
|
||
|
||
# Flatten tool_response content to plain text (pattern from agent-outcome-backfill.sh).
|
||
RESPONSE=$(printf '%s' "$PAYLOAD" | jq -r '
|
||
(.tool_response // "") as $r | def f:
|
||
if type=="string" then . elif type=="array" then map(f)|join("\n")
|
||
elif type=="object" then (if has("text") then .text elif has("content") then .content|f else tostring end)
|
||
else "" end; $r|f' 2>/dev/null || true)
|
||
|
||
# Parse outcome from STATUS-TRUTH MARKER; null if absent or unrecognized.
|
||
OUTCOME="null"
|
||
if printf '%s' "$RESPONSE" | grep -q '=== STATUS-TRUTH MARKER ===' 2>/dev/null; then
|
||
SHIPPED=$(printf '%s' "$RESPONSE" | grep -m1 '^shipped:' \
|
||
| sed 's/^shipped:[[:space:]]*//' | awk '{print tolower($1)}' 2>/dev/null || true)
|
||
case "$SHIPPED" in functional|partial|scaffolding|fail) OUTCOME="\"$SHIPPED\"";; esac
|
||
fi
|
||
|
||
# Cost estimate from token counts × rough per-token price constants.
|
||
MODEL=$(printf '%s' "$PAYLOAD" | jq -r '.tool_response.model // .tool_input.model // ""' 2>/dev/null | tr '[:upper:]' '[:lower:]')
|
||
IN_TOK=$(printf '%s' "$PAYLOAD" | jq -r '.tool_response.usage.input_tokens // 0' 2>/dev/null)
|
||
OUT_TOK=$(printf '%s' "$PAYLOAD" | jq -r '.tool_response.usage.output_tokens // 0' 2>/dev/null)
|
||
cost_usd=$(awk -v m="$MODEL" -v i="$IN_TOK" -v o="$OUT_TOK" 'BEGIN{
|
||
if(index(m,"haiku")>0){p=0.000001;q=0.000005}
|
||
else if(index(m,"sonnet")>0){p=0.000003;q=0.000015}
|
||
else if(index(m,"opus")>0){p=0.000005;q=0.000025}
|
||
else{print "null";exit}; printf "%.6f",i*p+o*q}' 2>/dev/null || echo "null")
|
||
|
||
jq -cn \
|
||
--arg ts "$(date -u +%Y-%m-%dT%H:%M:%S.000Z 2>/dev/null)" \
|
||
--arg id "$TOOL_USE_ID" \
|
||
--argjson outcome "$OUTCOME" \
|
||
--argjson duration_ms "$(printf '%s' "$PAYLOAD" | jq '.duration_ms // .tool_response.totalDurationMs // null' 2>/dev/null)" \
|
||
--argjson tool_use_count "$(printf '%s' "$PAYLOAD" | jq '.tool_response.totalToolUseCount // null' 2>/dev/null)" \
|
||
--argjson cost_usd "$cost_usd" \
|
||
'{ts:$ts,event:"agent_done",id:$id,outcome:$outcome,
|
||
duration_ms:$duration_ms,tool_use_count:$tool_use_count,cost_usd:$cost_usd}' \
|
||
>> "$EVENTS_FILE" 2>/dev/null || true
|
||
|
||
exit 0
|