User pushback: live-graph showed only "main" node, no pulses on agents.
Root cause: hook stdin doesn't carry parent_tool_use_id for sub-agent
tool calls — we only get the sub-agent's own session_id, which doesn't
link back to the spawn's tool_use_id.
Sequential heuristic via shared state file:
- agent-event-spawn.sh appends tool_use_id to /tmp/kei-active-children.tsv
- tool-use-event.sh reads the LAST line of that file → uses that
tool_use_id as agent_id for the emitted event
- agent-event-done.sh removes the spawn's line (grep -v + atomic mv)
Verified end-to-end: a code-implementer agent ran 5 Bash calls during
its lifetime — all 5 tool_use events were correctly attributed to the
spawn's tool_use_id. After agent_done, subsequent orchestrator-direct
tool calls correctly fall back to agent_id="main".
Limitation: parallel agents may misattribute. The "most recent live
spawn" heuristic works for single-agent-at-a-time which is the common
case. Parallel spawns share /tmp/kei-active-children.tsv and a sub-
agent's tool calls all attribute to whichever spawn appended last.
Acceptable for v1 demo; proper parent-tool-use-id propagation requires
Claude Code to expose it in sub-agent stdin (upstream change).
The `mv` after `grep -v` runs UNCONDITIONALLY (not gated on grep's
exit code) — grep -v returns 1 when ALL lines match, which would
otherwise leave the stale file in place.
Bypass: `KEI_EVENTS_BYPASS=1` (existing) covers all 3 hooks.
Override path: `KEI_ACTIVE_SPAWNS_FILE=/path/to/file`.
=== STATUS-TRUTH MARKER ===
shipped: functional
stubs: 0
cargo-check: NOT-RUN
behaviour-verified: yes
follow-up-required:
- Parallel-agent attribution would need parent_tool_use_id from
Claude Code sub-agent stdin (not currently exposed).
- Race condition window between spawn append and done remove is
millisecond-scale; observed clean in single-agent demo.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
68 lines
3.3 KiB
Bash
Executable file
68 lines
3.3 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
|
||
|
||
# Remove this spawn from active-children ledger (mirror of spawn hook).
|
||
# `grep -v` returns exit 1 when the file becomes empty, so the `mv` runs
|
||
# UNCONDITIONALLY (not gated on grep's exit status).
|
||
ACTIVE_FILE="${KEI_ACTIVE_SPAWNS_FILE:-/tmp/kei-active-children.tsv}"
|
||
if [ -n "$TOOL_USE_ID" ] && [ -f "$ACTIVE_FILE" ]; then
|
||
grep -v " $TOOL_USE_ID\$" "$ACTIVE_FILE" > "$ACTIVE_FILE.tmp" 2>/dev/null
|
||
mv "$ACTIVE_FILE.tmp" "$ACTIVE_FILE" 2>/dev/null || true
|
||
fi
|
||
|
||
exit 0
|