Skip to content

[BUG] stop.sh silently discards token-usage data when the token-counter dashboard POST fails (offset commits before delivery confirms) #36

Description

@anish-nair-410

Version

3.10.8 (current main HEAD as of 2026-07-10)

Summary

The generated .dual-graph/stop.sh hook (templated in bin/dual_graph_launch.sh, the heredoc starting at # Write stop.sh — reads transcript, sums real API usage, POSTs to token counter) advances its <transcript>.stopoffset file unconditionally, before confirming that either of the two curl POSTs to the token-counter logging endpoints actually succeeded. Both POSTs are wrapped in -sf ... || true, so a failed delivery produces no error — and the offset has already moved past those transcript lines, so that chunk of usage is gone forever. The hook will never look at those lines again on subsequent Stop events.

Concretely, in the Python block embedded in stop.sh:

# Save current line count so next stop only counts new lines
try:
    with open(offset_file, "w") as f:
        f.write(str(len(lines)))
except Exception:
    pass
if input_tokens > 0 or cache_create > 0 or cache_read > 0 or output_tokens > 0:
    print(json.dumps({...}))

The offset write happens in the Python subprocess, which exits before bash ever attempts the curl calls below it. There is no feedback path from "did the POST land" back to "should the offset advance."

Why this causes real, permanent data loss (not just a display glitch)

Each token-counter-mcp instance appears to run its own local HTTP dashboard, and instances race over the shared ~/.claude/token-counter/dashboard-port.txt file (last writer wins). When two dgc/Claude Code sessions are open concurrently against the same project — a normal workflow, not an edge case — one session's dashboard can end up bound to a different port than what's currently written in the shared port file, or its dashboard process can die/restart mid-session. Any Stop event that fires during that window POSTs to a stale/wrong/dead port, curl -sf fails silently, and the offset has already advanced. There is no retry, and no way to recover the lost interval later since the offset file is the only pointer into "what's already been counted."

Repro

  1. Open two Claude Code (or other supported CLI) sessions against the same project directory, each launched via dgc, so each spawns its own token-counter-mcp process.
  2. Let both sessions run normally for a while (agentic turns that trigger Stop events).
  3. At some point during the session (a dashboard restart, a port takeover by the other session, anything that makes the currently-recorded dashboard-port.txt port unreachable for one session), that session's curl -sf -X POST http://127.0.0.1:$DASH_PORT/log calls start failing.
  4. Because the failure is swallowed (|| true) and the offset already advanced regardless, ~/.claude/token-counter/history.json permanently stops receiving entries for that session — with no error, no warning, and no way to tell from the tool itself that anything is wrong.

Observed impact

In our case this produced a ~48 hour gap in history.json (2026-07-08T10:58Z → 2026-07-10T11:09Z) across a long-running session, silently dropping ~632K input / ~2.78M output / ~1.21B cache-read / 14.4M cache-write tokens ($460 at Sonnet-5 rates) from the cost record. We only noticed because the numbers looked implausibly low compared to a concurrently-running second session. Recovery was only possible because the raw transcript .jsonl files were still intact — most users would just see a wrong, silently-lower get_session_stats()/dashboard total with no indication anything was missed.

Diagnostic trace (how we confirmed root cause, not just symptom)

  1. history.json entries jump discontinuously, skipping ~48h with zero entries in between, while the affected session kept running turns the whole time (confirmed via its .jsonl transcript's own message timestamps spanning the full gap).

  2. The .stopoffset file for that session's transcript had already advanced to within a few lines of end-of-file (e.g. offset at line 348 of a 353-line transcript, i.e. ~98.6% "consumed") despite history.json showing nothing logged for that session since the start of the gap. This is only possible if the offset write is unconditional — it directly demonstrates the hook believes it already delivered data it never actually sent.

  3. Two independent token-counter-mcp processes were running concurrently against the same project (ps aux | grep token-counter-mcp showed two separate node .../token-counter-mcp PIDs with different start times, one from a session opened days earlier and one from a freshly-opened session), each presumably running its own local dashboard HTTP server. ~/.claude/token-counter/dashboard-port.txt only ever holds one port value at a time — whichever process wrote it last. Restarting either session flips that value, so the other, still-running session's next Stop event reads a port that may no longer correspond to its own dashboard, and its curl -sf silently fails.

  4. Confirmed the currently-shipped stop.sh template is byte-for-byte identical to the flawed logic — diffed the heredoc in bin/dual_graph_launch.sh (the block starting # Write stop.sh — reads transcript, sums real API usage, POSTs to token counter) against a locally-installed .dual-graph/stop.sh generated from an earlier version; the offset-write-before-POST ordering is unchanged on current main (3.10.8).

  5. Checked whether this was already fixed or reported before filing. Two adjacent-sounding commits turned out to address different problems:

    • 4a83a2d (3.9.94, "fix token-counter MCP disconnect on multi-terminal") — stops a second terminal's launch from kill -9-ing the first terminal's already-running dashboard process. Doesn't touch stop.sh's offset/delivery logic.
    • ae4875b (3.10.1, "fix concurrent port race + bind localhost-only for security") — widens the internal MCP graph server's port scan (8080–8199) and binds it to localhost. Different server, different port range than the token-counter dashboard's dashboard-port.txt (8899+), and again doesn't touch stop.sh.

    Searched all 34 open+closed issues plus GitHub's issue-search API for stopoffset, "token counter lost", "dashboard silent" — no existing report matches. Closest is closed Concurrent dgc launches fail: "no free port in 8080-8099" (server scans 20 ports, launcher scans 120) #34 (the port-scan crash fixed by 3.10.1 above — different bug, different file) and open EXIT trap does not remove MCP config from .claude.json, causing "dual-graph failed" on next direct launch #2 (.claude.json cleanup on exit — unrelated).

Suggested fix

Make offset advancement conditional on confirmed delivery: have the Python block also emit the computed new offset value (not just the usage payload), and only write .stopoffset in bash after at least one of the two curl POSTs returns success. If neither succeeds, leave the offset untouched so the next Stop event naturally retries the full un-delivered range. Example patch we applied locally (adjust to match dual_graph_launch.sh's heredoc/\$-escaping conventions):

# python block prints two lines instead of one: new_offset, then the JSON payload
RESULT=$(python3 - "$TRANSCRIPT" << 'PYEOF'
...
if input_tokens > 0 or cache_create > 0 or cache_read > 0 or output_tokens > 0:
    print(len(lines))
    print(json.dumps({...}))
PYEOF
)
if [[ -n "$RESULT" ]]; then
  NEW_OFFSET=$(echo "$RESULT" | head -n1)
  USAGE=$(echo "$RESULT" | tail -n +2)
  DELIVERED=0
  MCP_PORT=$(cat "$RUN_DIR/mcp_port" 2>/dev/null || echo "$MCP_PORT")
  curl -sf -X POST "http://127.0.0.1:$MCP_PORT/log" -H "Content-Type: application/json" -d "$USAGE" >/dev/null 2>&1 && DELIVERED=1
  DASH_PORT=8899
  [[ -f "$HOME/.claude/token-counter/dashboard-port.txt" ]] && DASH_PORT=$(cat "$HOME/.claude/token-counter/dashboard-port.txt")
  curl -sf -X POST "http://127.0.0.1:$DASH_PORT/log" -H "Content-Type: application/json" -d "$USAGE" >/dev/null 2>&1 && DELIVERED=1
  [[ "$DELIVERED" == "1" ]] && echo -n "$NEW_OFFSET" > "$TRANSCRIPT.stopoffset"
fi

Happy to open a PR with this if useful — wanted to file the report first in case there's context I'm missing about why offset-then-POST was chosen deliberately (e.g. avoiding unbounded re-scan growth on a permanently-dead dashboard). A possible middle ground there: cap retry to the last N un-delivered lines, or log once to a local disk fallback file so nothing is lost even if the dashboard is down indefinitely.

Environment

  • OS: macOS (Darwin 25.5.0)
  • Shell: zsh
  • Reproduced with two concurrent Claude Code CLI sessions against the same project via dgc

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions