You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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 linestry:
withopen(offset_file, "w") asf:
f.write(str(len(lines)))
exceptException:
passifinput_tokens>0orcache_create>0orcache_read>0oroutput_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
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.
Let both sessions run normally for a while (agentic turns that trigger Stop events).
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.
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)
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).
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.
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.
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).
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.
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
Version
3.10.8 (current main HEAD as of 2026-07-10)
Summary
The generated
.dual-graph/stop.shhook (templated inbin/dual_graph_launch.sh, the heredoc starting at# Write stop.sh — reads transcript, sums real API usage, POSTs to token counter) advances its<transcript>.stopoffsetfile unconditionally, before confirming that either of the twocurlPOSTs 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:The offset write happens in the Python subprocess, which exits before bash ever attempts the
curlcalls 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-mcpinstance appears to run its own local HTTP dashboard, and instances race over the shared~/.claude/token-counter/dashboard-port.txtfile (last writer wins). When twodgc/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 -sffails 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
dgc, so each spawns its owntoken-counter-mcpprocess.dashboard-port.txtport unreachable for one session), that session'scurl -sf -X POST http://127.0.0.1:$DASH_PORT/logcalls start failing.|| true) and the offset already advanced regardless,~/.claude/token-counter/history.jsonpermanently 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.jsonlfiles were still intact — most users would just see a wrong, silently-lowerget_session_stats()/dashboard total with no indication anything was missed.Diagnostic trace (how we confirmed root cause, not just symptom)
history.jsonentries jump discontinuously, skipping ~48h with zero entries in between, while the affected session kept running turns the whole time (confirmed via its.jsonltranscript's own message timestamps spanning the full gap).The
.stopoffsetfile 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") despitehistory.jsonshowing 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.Two independent
token-counter-mcpprocesses were running concurrently against the same project (ps aux | grep token-counter-mcpshowed two separatenode .../token-counter-mcpPIDs 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.txtonly 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 itscurl -sfsilently fails.Confirmed the currently-shipped
stop.shtemplate is byte-for-byte identical to the flawed logic — diffed the heredoc inbin/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.shgenerated from an earlier version; the offset-write-before-POST ordering is unchanged on currentmain(3.10.8).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 fromkill -9-ing the first terminal's already-running dashboard process. Doesn't touchstop.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'sdashboard-port.txt(8899+), and again doesn't touchstop.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 Concurrentdgclaunches 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.jsoncleanup 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
.stopoffsetin bash after at least one of the twocurlPOSTs 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 matchdual_graph_launch.sh's heredoc/\$-escaping conventions):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
dgc