[Bug]: Nightly: Claude Code burns tokens every 5 minutes while t3code is running idle #2191

Description

@reasv

Before submitting

  • I searched existing issues and did not find a duplicate.
  • I included enough detail to reproduce or investigate the problem.

Area

apps/web

Steps to reproduce

I'm using t3code nightly with npx t3@nightly on Linux, along with the latest version of Claude Code (2.1.114), configured with API (not subscription) through a local HAProxy instance that sits between CC and AWS in order to monitor requests.
The issue starts as soon as I launch t3code via npx.

Expected behavior

Claude Code should not perform any API requests while t3code is running before any task has been issued.

Actual behavior

As soon as I start t3code, HAProxy immediately logs a POST /v1/messages?beta=true HTTP/1.1 request, and I can see that it has used up some tokens in the process, the amount varies, but it's usually over 10k.
The user agent is always claude-cli/2.1.114 (external, sdk-ts, agent-sdk/0.2.111).
This repeats every 5 minutes on the clock, and stops as soon as I kill t3code.

This doesn't happen with Claude Code on its own, when eg. leaving the TUI open with an ongoing conversation session.

The 5 minute cadence makes me think this is some kind of cache refresh. I don't store the actual requests, so I don't know what it was sending. This happens regardless of whether I ever open the WebUI.

Impact

Blocks work completely

Version or commit

0.0.21-nightly.20260417.58

Environment

Ubuntu 24.04

Logs or stack traces

From my HAProxy:
172.20.0.1:37872 [18/Apr/2026:21:47:33.729] ... {claude-cli/2.1.114 ...} "POST /v1/messages?beta=true HTTP/1.1"
172.20.0.1:60910 [18/Apr/2026:21:52:35.283] ... {claude-cli/2.1.114 ...} "POST /v1/messages?beta=true HTTP/1.1"
172.20.0.1:37290 [18/Apr/2026:21:57:40.132] ... {claude-cli/2.1.114 ...} "POST /v1/messages?beta=true HTTP/1.1"

Screenshots, recordings, or supporting files

No response

Workaround

I asked Claude Code/Opus 4.6 to investigate this issue, and it came with a plausible explanation of the issue and a possible fix.
I felt like I should file this bug ASAP so I haven't properly applied and tested its fix yet, but if it works for me and it seems reasonable to the maintainers I can come up with a PR.

Everything after this line was produced by Claude:

Summary

When t3code is running with Claude Code configured, the server makes a real POST /v1/messages request to the Anthropic API every ~5 minutes, even with no browser open and no user interaction. Each request consumes tens of thousands of tokens because it sends the full Claude Code system prompt. Left running overnight, this silently drains significant API credit.

The requests originate from probeClaudeCapabilities() in apps/server/src/provider/Layers/ClaudeProvider.ts. This function is intended to be a lightweight, zero-cost probe that reads local account metadata from a Claude Code subprocess without ever hitting the API. Due to two bugs -- one in t3code and one in the Claude Agent SDK -- it instead sends a real prompt (".") to the Anthropic messages endpoint every time it fires.

Evidence

HAProxy logs showing 5-minute-interval requests with no user activity (t3code started, browser never opened):

172.20.0.1:37872 [18/Apr/2026:21:47:33.729] ... {claude-cli/2.1.114 ...} "POST /v1/messages?beta=true HTTP/1.1"
172.20.0.1:60910 [18/Apr/2026:21:52:35.283] ... {claude-cli/2.1.114 ...} "POST /v1/messages?beta=true HTTP/1.1"
172.20.0.1:37290 [18/Apr/2026:21:57:40.132] ... {claude-cli/2.1.114 ...} "POST /v1/messages?beta=true HTTP/1.1"

The ~5-minute interval, the claude-cli user agent, and the ~1.4s response times are all consistent with this analysis.

How the probe is supposed to work

probeClaudeCapabilities() (line 494) exists to discover the user's subscription type and available slash commands when claude auth status does not include that information. It is called as a cache-miss handler behind an Effect Cache with a 5-minute TTL, which itself is consulted on every iteration of the 60-second provider health check loop.

The intended flow, per the comment at line 483-491:

The prompt is never sent to the Anthropic API -- we abort immediately after the local initialization phase completes. This gives us the user's subscription type without incurring any token cost.

The code uses the SDK's query() function with maxTurns: 0 and an AbortController, expecting the subprocess to initialize locally (returning account info via IPC), then be killed before it ever calls the API:

constprobeClaudeCapabilities=(binaryPath: string)=>{constabort=newAbortController();returnEffect.tryPromise(async()=>{constq=claudeQuery({prompt: ".",options: {maxTurns: 0,abortController: abort,persistSession: false,// ...},});constinit=awaitq.initializationResult();return{subscriptionType: init.account?.subscriptionType,slashCommands: parseClaudeInitializationCommands(init.commands),};}).pipe(Effect.ensuring(Effect.sync(()=>{if(!abort.signal.aborted)abort.abort();})),// ...);};

Why it actually makes an API call

Two bugs combine to defeat the intended behavior.

Bug 1: maxTurns: 0 is silently ignored by the SDK

Inside the Agent SDK (sdk.mjs), CLI arguments are built in the QX (ProcessTransport) constructor's initialize() method. The maxTurns option is handled as:

if(K)p.push("--max-turns",K.toString());

where K is the value of maxTurns. In JavaScript, 0 is falsy. if (0) evaluates to false, so --max-turns is never passed to the claude subprocess. The process starts with unlimited turns.

This is a bug in @anthropic-ai/claude-agent-sdk. The check should be if (K != null) or if (K !== undefined) to allow an explicit zero value.

Bug 2: The prompt is written to stdin before initializationResult() resolves

The SDK's query() export (E$$ in the minified source) works like this:

functionE$$({prompt, options}){// 1. Spawn the subprocess immediately (deferSpawn is undefined/falsy)let{queryInstance, transport, abortController}=WH(options,typeofprompt==="string");// 2. Write the prompt to subprocess stdin RIGHT NOW, synchronouslyzH(queryInstance,transport,prompt,abortController);// 3. Return the query handle (caller will await initializationResult() later)returnqueryInstance;}

zH() serializes the user message and writes it to the subprocess's stdin pipe:

functionzH(queryInstance,transport,prompt,abortController){if(typeofprompt==="string")transport.write(JSON.stringify({type: "user",session_id: "",message: {role: "user",content: [{type: "text",text: "."}]},parent_tool_use_id: null,})+"\n");}

This means the prompt "." lands in the subprocess's stdin buffer beforeprobeClaudeCapabilities even begins awaiting initializationResult().

The race the subprocess sees

Subprocess starts
|-- Reads stdin (stream-json mode)
|-- Receives IPC {subtype: "initialize"} control message
|-- Sends back init response (local file reads only, no API call)
|-- Receives user message {role: "user", content: "."}
|-- Begins processing user turn --> POST /v1/messages <-- THE LEAK
| (full system prompt + tools + "." = tens of thousands of tokens)
|
`-- SIGTERM arrives (from abort.abort() in Effect.ensuring)
... but the HTTP request is already in flight or completed

The initialization IPC round-trip completes in milliseconds (it only reads local files). But by then the prompt has been sitting in the stdin buffer the entire time. The subprocess processes messages in order, so it hits the user message almost immediately after responding to the initialize request. The abort fires only after initializationResult() resolves back in the Node.js event loop -- too late.

When the probe fires

The probe fires when both of these conditions are true:

  1. claude auth status JSON output does not contain a subscription type field. The current format is {"loggedIn": true, "authMethod": "..."} with no subscriptionType key, so this condition is met for all users.
  2. The 5-minute Effect Cache TTL has expired (or it is the first call).

The 60-second health check loop hits Cache.get() every minute. The cache absorbs 4 out of every 5 calls, but on every 5th call (cache miss), probeClaudeCapabilities() fires and makes a real API request.

Timeline:

t=0s Server starts --> first probe fires (cache cold)
t=60s Health check --> cache hit, no probe
t=120s Health check --> cache hit
t=180s Health check --> cache hit
t=240s Health check --> cache hit
t=300s Health check --> cache MISS (TTL expired) --> probe fires, API call
t=360s Health check --> cache hit (just refreshed)
...
t=600s Health check --> cache MISS --> probe fires, API call

This matches the observed ~5-minute interval in the HAProxy logs.

Impact

  • Token cost: Each probe sends the full Claude Code system prompt (tool definitions, project context, CLAUDE.md, etc.) plus the "." user message. This is tens of thousands of input tokens per request, plus a small model response.
  • Recurring: Fires every 5 minutes, 24/7, as long as t3code's server process is running.
  • Silent: No UI indication. No browser needs to be open. No user interaction required.
  • Unavoidable: Fires for any user with Claude Code configured as a provider, regardless of whether they are actively using it.

An overnight session (~8 hours) would make ~96 API requests, consuming potentially millions of tokens for zero value.

Suggested fix

Primary fix: use startup() instead of query()

The SDK exports a startup() function (exported as S$$ / startup in sdk.mjs) that is explicitly designed for the pre-warm / probe use case. It:

  1. Spawns the subprocess
  2. Awaits initializationResult() internally
  3. Returns a WarmQuery handle with .query() and .close() methods
  4. Does NOT write any prompt to stdin until .query() is explicitly called

If you call .close() without ever calling .query(), no prompt is ever sent and no API call is made. This is exactly the behavior probeClaudeCapabilities needs:

import{startup}from"@anthropic-ai/claude-agent-sdk";constprobeClaudeCapabilities=(binaryPath: string)=>{returnEffect.tryPromise(async()=>{constwarm=awaitstartup({options: {persistSession: false,pathToClaudeCodeExecutable: binaryPath,settingSources: ["user","project","local"],allowedTools: [],stderr: ()=>{},},initializeTimeoutMs: CAPABILITIES_PROBE_TIMEOUT_MS,});try{// startup() has already awaited initializationResult() internally.// Access the init data through the underlying query instance to// extract subscriptionType and slash commands.// (The exact access pattern depends on whether the SDK exposes the// init data on the WarmQuery handle -- if not, this may need a// small SDK-side change to surface it.)}finally{warm.close();// No prompt was ever written. No API call. Zero tokens.}}).pipe(Effect.timeoutOption(CAPABILITIES_PROBE_TIMEOUT_MS),Effect.result,Effect.map((result)=>{if(Result.isFailure(result))returnundefined;returnOption.isSome(result.success) ? result.success.value : undefined;}),);};

Note: startup() awaits initializationResult() internally but does not directly expose the resolved init data on the returned WarmQuery handle. This means the fix may also require either:

  • A small change to the SDK to expose initializationResult data on the WarmQuery return value, or
  • Accessing the underlying query instance's already-resolved initialization promise (it was awaited inside startup(), so re-awaiting it is synchronous).

Secondary fix (SDK-side): handle maxTurns: 0 correctly

The check in the SDK's process argument builder should be changed from:

if(K)p.push("--max-turns",K.toString());

to:

if(K!=null)p.push("--max-turns",K.toString());

This would allow maxTurns: 0 to actually pass --max-turns 0 to the CLI, which would be a correct safety net even when using query(). However, this alone would not fully fix the issue because the prompt is still written to stdin before initialization completes -- the subprocess would still receive the user message, it would just (hopefully) refuse to process it due to the turn limit. The startup() approach is more robust because it avoids writing the prompt entirely.

Additional hardening: increase cache TTL or skip probe for API key users

For users authenticated via API key (authMethod: "api-key"), the subscription type is not meaningful (they are billed per-token regardless). The probe could be skipped entirely for these users, since claude auth status already reports the auth method. This would eliminate the leak for the most cost-sensitive user segment.

Independently, increasing the cache TTL from 5 minutes to something much longer (e.g., 1 hour) would reduce the frequency of probes. Subscription type and slash commands change very rarely, so a longer TTL has no practical downside.

Files involved

FileRole
apps/server/src/provider/Layers/ClaudeProvider.tsprobeClaudeCapabilities() (line 494), cache setup (line 794), checkClaudeProviderStatus() guard (line 679)
apps/server/src/provider/makeManagedServerProvider.ts60-second refresh loop (line 133)
node_modules/@anthropic-ai/claude-agent-sdk/sdk.mjsE$$ (query), S$$ (startup), WH (transport setup), zH (prompt write), QX.initialize (maxTurns flag builder)

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething is broken or behaving incorrectly.needs-triageIssue needs maintainer review and initial categorization.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions

      , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
       blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
      }
      } catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
      })();
      (function(){
      try {
      var __m = "github.com";
      var __re = new RegExp('^' + "github\\.com" + '
      
      Skip to content

      [Bug]: Nightly: Claude Code burns tokens every 5 minutes while t3code is running idle #2191

      Description

      @reasv

      Before submitting

      • I searched existing issues and did not find a duplicate.
      • I included enough detail to reproduce or investigate the problem.

      Area

      apps/web

      Steps to reproduce

      I'm using t3code nightly with npx t3@nightly on Linux, along with the latest version of Claude Code (2.1.114), configured with API (not subscription) through a local HAProxy instance that sits between CC and AWS in order to monitor requests.
      The issue starts as soon as I launch t3code via npx.

      Expected behavior

      Claude Code should not perform any API requests while t3code is running before any task has been issued.

      Actual behavior

      As soon as I start t3code, HAProxy immediately logs a POST /v1/messages?beta=true HTTP/1.1 request, and I can see that it has used up some tokens in the process, the amount varies, but it's usually over 10k.
      The user agent is always claude-cli/2.1.114 (external, sdk-ts, agent-sdk/0.2.111).
      This repeats every 5 minutes on the clock, and stops as soon as I kill t3code.

      This doesn't happen with Claude Code on its own, when eg. leaving the TUI open with an ongoing conversation session.

      The 5 minute cadence makes me think this is some kind of cache refresh. I don't store the actual requests, so I don't know what it was sending. This happens regardless of whether I ever open the WebUI.

      Impact

      Blocks work completely

      Version or commit

      0.0.21-nightly.20260417.58

      Environment

      Ubuntu 24.04

      Logs or stack traces

      From my HAProxy:
      172.20.0.1:37872 [18/Apr/2026:21:47:33.729] ... {claude-cli/2.1.114 ...} "POST /v1/messages?beta=true HTTP/1.1"
      172.20.0.1:60910 [18/Apr/2026:21:52:35.283] ... {claude-cli/2.1.114 ...} "POST /v1/messages?beta=true HTTP/1.1"
      172.20.0.1:37290 [18/Apr/2026:21:57:40.132] ... {claude-cli/2.1.114 ...} "POST /v1/messages?beta=true HTTP/1.1"

      Screenshots, recordings, or supporting files

      No response

      Workaround

      I asked Claude Code/Opus 4.6 to investigate this issue, and it came with a plausible explanation of the issue and a possible fix.
      I felt like I should file this bug ASAP so I haven't properly applied and tested its fix yet, but if it works for me and it seems reasonable to the maintainers I can come up with a PR.

      Everything after this line was produced by Claude:

      Summary

      When t3code is running with Claude Code configured, the server makes a real POST /v1/messages request to the Anthropic API every ~5 minutes, even with no browser open and no user interaction. Each request consumes tens of thousands of tokens because it sends the full Claude Code system prompt. Left running overnight, this silently drains significant API credit.

      The requests originate from probeClaudeCapabilities() in apps/server/src/provider/Layers/ClaudeProvider.ts. This function is intended to be a lightweight, zero-cost probe that reads local account metadata from a Claude Code subprocess without ever hitting the API. Due to two bugs -- one in t3code and one in the Claude Agent SDK -- it instead sends a real prompt (".") to the Anthropic messages endpoint every time it fires.

      Evidence

      HAProxy logs showing 5-minute-interval requests with no user activity (t3code started, browser never opened):

      172.20.0.1:37872 [18/Apr/2026:21:47:33.729] ... {claude-cli/2.1.114 ...} "POST /v1/messages?beta=true HTTP/1.1"
      172.20.0.1:60910 [18/Apr/2026:21:52:35.283] ... {claude-cli/2.1.114 ...} "POST /v1/messages?beta=true HTTP/1.1"
      172.20.0.1:37290 [18/Apr/2026:21:57:40.132] ... {claude-cli/2.1.114 ...} "POST /v1/messages?beta=true HTTP/1.1"
      

      The ~5-minute interval, the claude-cli user agent, and the ~1.4s response times are all consistent with this analysis.

      How the probe is supposed to work

      probeClaudeCapabilities() (line 494) exists to discover the user's subscription type and available slash commands when claude auth status does not include that information. It is called as a cache-miss handler behind an Effect Cache with a 5-minute TTL, which itself is consulted on every iteration of the 60-second provider health check loop.

      The intended flow, per the comment at line 483-491:

      The prompt is never sent to the Anthropic API -- we abort immediately after the local initialization phase completes. This gives us the user's subscription type without incurring any token cost.

      The code uses the SDK's query() function with maxTurns: 0 and an AbortController, expecting the subprocess to initialize locally (returning account info via IPC), then be killed before it ever calls the API:

      constprobeClaudeCapabilities=(binaryPath: string)=>{constabort=newAbortController();returnEffect.tryPromise(async()=>{constq=claudeQuery({prompt: ".",options: {maxTurns: 0,abortController: abort,persistSession: false,// ...},});constinit=awaitq.initializationResult();return{subscriptionType: init.account?.subscriptionType,slashCommands: parseClaudeInitializationCommands(init.commands),};}).pipe(Effect.ensuring(Effect.sync(()=>{if(!abort.signal.aborted)abort.abort();})),// ...);};

      Why it actually makes an API call

      Two bugs combine to defeat the intended behavior.

      Bug 1: maxTurns: 0 is silently ignored by the SDK

      Inside the Agent SDK (sdk.mjs), CLI arguments are built in the QX (ProcessTransport) constructor's initialize() method. The maxTurns option is handled as:

      if(K)p.push("--max-turns",K.toString());

      where K is the value of maxTurns. In JavaScript, 0 is falsy. if (0) evaluates to false, so --max-turns is never passed to the claude subprocess. The process starts with unlimited turns.

      This is a bug in @anthropic-ai/claude-agent-sdk. The check should be if (K != null) or if (K !== undefined) to allow an explicit zero value.

      Bug 2: The prompt is written to stdin before initializationResult() resolves

      The SDK's query() export (E$$ in the minified source) works like this:

      functionE$$({prompt, options}){// 1. Spawn the subprocess immediately (deferSpawn is undefined/falsy)let{queryInstance, transport, abortController}=WH(options,typeofprompt==="string");// 2. Write the prompt to subprocess stdin RIGHT NOW, synchronouslyzH(queryInstance,transport,prompt,abortController);// 3. Return the query handle (caller will await initializationResult() later)returnqueryInstance;}

      zH() serializes the user message and writes it to the subprocess's stdin pipe:

      functionzH(queryInstance,transport,prompt,abortController){if(typeofprompt==="string")transport.write(JSON.stringify({type: "user",session_id: "",message: {role: "user",content: [{type: "text",text: "."}]},parent_tool_use_id: null,})+"\n");}

      This means the prompt "." lands in the subprocess's stdin buffer beforeprobeClaudeCapabilities even begins awaiting initializationResult().

      The race the subprocess sees

      Subprocess starts
      |-- Reads stdin (stream-json mode)
      |-- Receives IPC {subtype: "initialize"} control message
      |-- Sends back init response (local file reads only, no API call)
      |-- Receives user message {role: "user", content: "."}
      |-- Begins processing user turn --> POST /v1/messages <-- THE LEAK
      | (full system prompt + tools + "." = tens of thousands of tokens)
      |
      `-- SIGTERM arrives (from abort.abort() in Effect.ensuring)
      ... but the HTTP request is already in flight or completed
      

      The initialization IPC round-trip completes in milliseconds (it only reads local files). But by then the prompt has been sitting in the stdin buffer the entire time. The subprocess processes messages in order, so it hits the user message almost immediately after responding to the initialize request. The abort fires only after initializationResult() resolves back in the Node.js event loop -- too late.

      When the probe fires

      The probe fires when both of these conditions are true:

      1. claude auth status JSON output does not contain a subscription type field. The current format is {"loggedIn": true, "authMethod": "..."} with no subscriptionType key, so this condition is met for all users.
      2. The 5-minute Effect Cache TTL has expired (or it is the first call).

      The 60-second health check loop hits Cache.get() every minute. The cache absorbs 4 out of every 5 calls, but on every 5th call (cache miss), probeClaudeCapabilities() fires and makes a real API request.

      Timeline:

      t=0s Server starts --> first probe fires (cache cold)
      t=60s Health check --> cache hit, no probe
      t=120s Health check --> cache hit
      t=180s Health check --> cache hit
      t=240s Health check --> cache hit
      t=300s Health check --> cache MISS (TTL expired) --> probe fires, API call
      t=360s Health check --> cache hit (just refreshed)
      ...
      t=600s Health check --> cache MISS --> probe fires, API call
      

      This matches the observed ~5-minute interval in the HAProxy logs.

      Impact

      • Token cost: Each probe sends the full Claude Code system prompt (tool definitions, project context, CLAUDE.md, etc.) plus the "." user message. This is tens of thousands of input tokens per request, plus a small model response.
      • Recurring: Fires every 5 minutes, 24/7, as long as t3code's server process is running.
      • Silent: No UI indication. No browser needs to be open. No user interaction required.
      • Unavoidable: Fires for any user with Claude Code configured as a provider, regardless of whether they are actively using it.

      An overnight session (~8 hours) would make ~96 API requests, consuming potentially millions of tokens for zero value.

      Suggested fix

      Primary fix: use startup() instead of query()

      The SDK exports a startup() function (exported as S$$ / startup in sdk.mjs) that is explicitly designed for the pre-warm / probe use case. It:

      1. Spawns the subprocess
      2. Awaits initializationResult() internally
      3. Returns a WarmQuery handle with .query() and .close() methods
      4. Does NOT write any prompt to stdin until .query() is explicitly called

      If you call .close() without ever calling .query(), no prompt is ever sent and no API call is made. This is exactly the behavior probeClaudeCapabilities needs:

      import{startup}from"@anthropic-ai/claude-agent-sdk";constprobeClaudeCapabilities=(binaryPath: string)=>{returnEffect.tryPromise(async()=>{constwarm=awaitstartup({options: {persistSession: false,pathToClaudeCodeExecutable: binaryPath,settingSources: ["user","project","local"],allowedTools: [],stderr: ()=>{},},initializeTimeoutMs: CAPABILITIES_PROBE_TIMEOUT_MS,});try{// startup() has already awaited initializationResult() internally.// Access the init data through the underlying query instance to// extract subscriptionType and slash commands.// (The exact access pattern depends on whether the SDK exposes the// init data on the WarmQuery handle -- if not, this may need a// small SDK-side change to surface it.)}finally{warm.close();// No prompt was ever written. No API call. Zero tokens.}}).pipe(Effect.timeoutOption(CAPABILITIES_PROBE_TIMEOUT_MS),Effect.result,Effect.map((result)=>{if(Result.isFailure(result))returnundefined;returnOption.isSome(result.success) ? result.success.value : undefined;}),);};

      Note: startup() awaits initializationResult() internally but does not directly expose the resolved init data on the returned WarmQuery handle. This means the fix may also require either:

      • A small change to the SDK to expose initializationResult data on the WarmQuery return value, or
      • Accessing the underlying query instance's already-resolved initialization promise (it was awaited inside startup(), so re-awaiting it is synchronous).

      Secondary fix (SDK-side): handle maxTurns: 0 correctly

      The check in the SDK's process argument builder should be changed from:

      if(K)p.push("--max-turns",K.toString());

      to:

      if(K!=null)p.push("--max-turns",K.toString());

      This would allow maxTurns: 0 to actually pass --max-turns 0 to the CLI, which would be a correct safety net even when using query(). However, this alone would not fully fix the issue because the prompt is still written to stdin before initialization completes -- the subprocess would still receive the user message, it would just (hopefully) refuse to process it due to the turn limit. The startup() approach is more robust because it avoids writing the prompt entirely.

      Additional hardening: increase cache TTL or skip probe for API key users

      For users authenticated via API key (authMethod: "api-key"), the subscription type is not meaningful (they are billed per-token regardless). The probe could be skipped entirely for these users, since claude auth status already reports the auth method. This would eliminate the leak for the most cost-sensitive user segment.

      Independently, increasing the cache TTL from 5 minutes to something much longer (e.g., 1 hour) would reduce the frequency of probes. Subscription type and slash commands change very rarely, so a longer TTL has no practical downside.

      Files involved

      FileRole
      apps/server/src/provider/Layers/ClaudeProvider.tsprobeClaudeCapabilities() (line 494), cache setup (line 794), checkClaudeProviderStatus() guard (line 679)
      apps/server/src/provider/makeManagedServerProvider.ts60-second refresh loop (line 133)
      node_modules/@anthropic-ai/claude-agent-sdk/sdk.mjsE$$ (query), S$$ (startup), WH (transport setup), zH (prompt write), QX.initialize (maxTurns flag builder)

      Metadata

      Metadata

      Assignees

      No one assigned

        Labels

        bugSomething is broken or behaving incorrectly.needs-triageIssue needs maintainer review and initial categorization.

        Type

        No type

        Projects

        No projects

          Milestone

          No milestone

          Relationships

          None yet

          Development

          No branches or pull requests

          Issue actions

          , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
          Skip to content

          [Bug]: Nightly: Claude Code burns tokens every 5 minutes while t3code is running idle #2191

          Description

          @reasv

          Before submitting

          • I searched existing issues and did not find a duplicate.
          • I included enough detail to reproduce or investigate the problem.

          Area

          apps/web

          Steps to reproduce

          I'm using t3code nightly with npx t3@nightly on Linux, along with the latest version of Claude Code (2.1.114), configured with API (not subscription) through a local HAProxy instance that sits between CC and AWS in order to monitor requests.
          The issue starts as soon as I launch t3code via npx.

          Expected behavior

          Claude Code should not perform any API requests while t3code is running before any task has been issued.

          Actual behavior

          As soon as I start t3code, HAProxy immediately logs a POST /v1/messages?beta=true HTTP/1.1 request, and I can see that it has used up some tokens in the process, the amount varies, but it's usually over 10k.
          The user agent is always claude-cli/2.1.114 (external, sdk-ts, agent-sdk/0.2.111).
          This repeats every 5 minutes on the clock, and stops as soon as I kill t3code.

          This doesn't happen with Claude Code on its own, when eg. leaving the TUI open with an ongoing conversation session.

          The 5 minute cadence makes me think this is some kind of cache refresh. I don't store the actual requests, so I don't know what it was sending. This happens regardless of whether I ever open the WebUI.

          Impact

          Blocks work completely

          Version or commit

          0.0.21-nightly.20260417.58

          Environment

          Ubuntu 24.04

          Logs or stack traces

          From my HAProxy:
          172.20.0.1:37872 [18/Apr/2026:21:47:33.729] ... {claude-cli/2.1.114 ...} "POST /v1/messages?beta=true HTTP/1.1"
          172.20.0.1:60910 [18/Apr/2026:21:52:35.283] ... {claude-cli/2.1.114 ...} "POST /v1/messages?beta=true HTTP/1.1"
          172.20.0.1:37290 [18/Apr/2026:21:57:40.132] ... {claude-cli/2.1.114 ...} "POST /v1/messages?beta=true HTTP/1.1"

          Screenshots, recordings, or supporting files

          No response

          Workaround

          I asked Claude Code/Opus 4.6 to investigate this issue, and it came with a plausible explanation of the issue and a possible fix.
          I felt like I should file this bug ASAP so I haven't properly applied and tested its fix yet, but if it works for me and it seems reasonable to the maintainers I can come up with a PR.

          Everything after this line was produced by Claude:

          Summary

          When t3code is running with Claude Code configured, the server makes a real POST /v1/messages request to the Anthropic API every ~5 minutes, even with no browser open and no user interaction. Each request consumes tens of thousands of tokens because it sends the full Claude Code system prompt. Left running overnight, this silently drains significant API credit.

          The requests originate from probeClaudeCapabilities() in apps/server/src/provider/Layers/ClaudeProvider.ts. This function is intended to be a lightweight, zero-cost probe that reads local account metadata from a Claude Code subprocess without ever hitting the API. Due to two bugs -- one in t3code and one in the Claude Agent SDK -- it instead sends a real prompt (".") to the Anthropic messages endpoint every time it fires.

          Evidence

          HAProxy logs showing 5-minute-interval requests with no user activity (t3code started, browser never opened):

          172.20.0.1:37872 [18/Apr/2026:21:47:33.729] ... {claude-cli/2.1.114 ...} "POST /v1/messages?beta=true HTTP/1.1"
          172.20.0.1:60910 [18/Apr/2026:21:52:35.283] ... {claude-cli/2.1.114 ...} "POST /v1/messages?beta=true HTTP/1.1"
          172.20.0.1:37290 [18/Apr/2026:21:57:40.132] ... {claude-cli/2.1.114 ...} "POST /v1/messages?beta=true HTTP/1.1"
          

          The ~5-minute interval, the claude-cli user agent, and the ~1.4s response times are all consistent with this analysis.

          How the probe is supposed to work

          probeClaudeCapabilities() (line 494) exists to discover the user's subscription type and available slash commands when claude auth status does not include that information. It is called as a cache-miss handler behind an Effect Cache with a 5-minute TTL, which itself is consulted on every iteration of the 60-second provider health check loop.

          The intended flow, per the comment at line 483-491:

          The prompt is never sent to the Anthropic API -- we abort immediately after the local initialization phase completes. This gives us the user's subscription type without incurring any token cost.

          The code uses the SDK's query() function with maxTurns: 0 and an AbortController, expecting the subprocess to initialize locally (returning account info via IPC), then be killed before it ever calls the API:

          constprobeClaudeCapabilities=(binaryPath: string)=>{constabort=newAbortController();returnEffect.tryPromise(async()=>{constq=claudeQuery({prompt: ".",options: {maxTurns: 0,abortController: abort,persistSession: false,// ...},});constinit=awaitq.initializationResult();return{subscriptionType: init.account?.subscriptionType,slashCommands: parseClaudeInitializationCommands(init.commands),};}).pipe(Effect.ensuring(Effect.sync(()=>{if(!abort.signal.aborted)abort.abort();})),// ...);};

          Why it actually makes an API call

          Two bugs combine to defeat the intended behavior.

          Bug 1: maxTurns: 0 is silently ignored by the SDK

          Inside the Agent SDK (sdk.mjs), CLI arguments are built in the QX (ProcessTransport) constructor's initialize() method. The maxTurns option is handled as:

          if(K)p.push("--max-turns",K.toString());

          where K is the value of maxTurns. In JavaScript, 0 is falsy. if (0) evaluates to false, so --max-turns is never passed to the claude subprocess. The process starts with unlimited turns.

          This is a bug in @anthropic-ai/claude-agent-sdk. The check should be if (K != null) or if (K !== undefined) to allow an explicit zero value.

          Bug 2: The prompt is written to stdin before initializationResult() resolves

          The SDK's query() export (E$$ in the minified source) works like this:

          functionE$$({prompt, options}){// 1. Spawn the subprocess immediately (deferSpawn is undefined/falsy)let{queryInstance, transport, abortController}=WH(options,typeofprompt==="string");// 2. Write the prompt to subprocess stdin RIGHT NOW, synchronouslyzH(queryInstance,transport,prompt,abortController);// 3. Return the query handle (caller will await initializationResult() later)returnqueryInstance;}

          zH() serializes the user message and writes it to the subprocess's stdin pipe:

          functionzH(queryInstance,transport,prompt,abortController){if(typeofprompt==="string")transport.write(JSON.stringify({type: "user",session_id: "",message: {role: "user",content: [{type: "text",text: "."}]},parent_tool_use_id: null,})+"\n");}

          This means the prompt "." lands in the subprocess's stdin buffer beforeprobeClaudeCapabilities even begins awaiting initializationResult().

          The race the subprocess sees

          Subprocess starts
          |-- Reads stdin (stream-json mode)
          |-- Receives IPC {subtype: "initialize"} control message
          |-- Sends back init response (local file reads only, no API call)
          |-- Receives user message {role: "user", content: "."}
          |-- Begins processing user turn --> POST /v1/messages <-- THE LEAK
          | (full system prompt + tools + "." = tens of thousands of tokens)
          |
          `-- SIGTERM arrives (from abort.abort() in Effect.ensuring)
          ... but the HTTP request is already in flight or completed
          

          The initialization IPC round-trip completes in milliseconds (it only reads local files). But by then the prompt has been sitting in the stdin buffer the entire time. The subprocess processes messages in order, so it hits the user message almost immediately after responding to the initialize request. The abort fires only after initializationResult() resolves back in the Node.js event loop -- too late.

          When the probe fires

          The probe fires when both of these conditions are true:

          1. claude auth status JSON output does not contain a subscription type field. The current format is {"loggedIn": true, "authMethod": "..."} with no subscriptionType key, so this condition is met for all users.
          2. The 5-minute Effect Cache TTL has expired (or it is the first call).

          The 60-second health check loop hits Cache.get() every minute. The cache absorbs 4 out of every 5 calls, but on every 5th call (cache miss), probeClaudeCapabilities() fires and makes a real API request.

          Timeline:

          t=0s Server starts --> first probe fires (cache cold)
          t=60s Health check --> cache hit, no probe
          t=120s Health check --> cache hit
          t=180s Health check --> cache hit
          t=240s Health check --> cache hit
          t=300s Health check --> cache MISS (TTL expired) --> probe fires, API call
          t=360s Health check --> cache hit (just refreshed)
          ...
          t=600s Health check --> cache MISS --> probe fires, API call
          

          This matches the observed ~5-minute interval in the HAProxy logs.

          Impact

          • Token cost: Each probe sends the full Claude Code system prompt (tool definitions, project context, CLAUDE.md, etc.) plus the "." user message. This is tens of thousands of input tokens per request, plus a small model response.
          • Recurring: Fires every 5 minutes, 24/7, as long as t3code's server process is running.
          • Silent: No UI indication. No browser needs to be open. No user interaction required.
          • Unavoidable: Fires for any user with Claude Code configured as a provider, regardless of whether they are actively using it.

          An overnight session (~8 hours) would make ~96 API requests, consuming potentially millions of tokens for zero value.

          Suggested fix

          Primary fix: use startup() instead of query()

          The SDK exports a startup() function (exported as S$$ / startup in sdk.mjs) that is explicitly designed for the pre-warm / probe use case. It:

          1. Spawns the subprocess
          2. Awaits initializationResult() internally
          3. Returns a WarmQuery handle with .query() and .close() methods
          4. Does NOT write any prompt to stdin until .query() is explicitly called

          If you call .close() without ever calling .query(), no prompt is ever sent and no API call is made. This is exactly the behavior probeClaudeCapabilities needs:

          import{startup}from"@anthropic-ai/claude-agent-sdk";constprobeClaudeCapabilities=(binaryPath: string)=>{returnEffect.tryPromise(async()=>{constwarm=awaitstartup({options: {persistSession: false,pathToClaudeCodeExecutable: binaryPath,settingSources: ["user","project","local"],allowedTools: [],stderr: ()=>{},},initializeTimeoutMs: CAPABILITIES_PROBE_TIMEOUT_MS,});try{// startup() has already awaited initializationResult() internally.// Access the init data through the underlying query instance to// extract subscriptionType and slash commands.// (The exact access pattern depends on whether the SDK exposes the// init data on the WarmQuery handle -- if not, this may need a// small SDK-side change to surface it.)}finally{warm.close();// No prompt was ever written. No API call. Zero tokens.}}).pipe(Effect.timeoutOption(CAPABILITIES_PROBE_TIMEOUT_MS),Effect.result,Effect.map((result)=>{if(Result.isFailure(result))returnundefined;returnOption.isSome(result.success) ? result.success.value : undefined;}),);};

          Note: startup() awaits initializationResult() internally but does not directly expose the resolved init data on the returned WarmQuery handle. This means the fix may also require either:

          • A small change to the SDK to expose initializationResult data on the WarmQuery return value, or
          • Accessing the underlying query instance's already-resolved initialization promise (it was awaited inside startup(), so re-awaiting it is synchronous).

          Secondary fix (SDK-side): handle maxTurns: 0 correctly

          The check in the SDK's process argument builder should be changed from:

          if(K)p.push("--max-turns",K.toString());

          to:

          if(K!=null)p.push("--max-turns",K.toString());

          This would allow maxTurns: 0 to actually pass --max-turns 0 to the CLI, which would be a correct safety net even when using query(). However, this alone would not fully fix the issue because the prompt is still written to stdin before initialization completes -- the subprocess would still receive the user message, it would just (hopefully) refuse to process it due to the turn limit. The startup() approach is more robust because it avoids writing the prompt entirely.

          Additional hardening: increase cache TTL or skip probe for API key users

          For users authenticated via API key (authMethod: "api-key"), the subscription type is not meaningful (they are billed per-token regardless). The probe could be skipped entirely for these users, since claude auth status already reports the auth method. This would eliminate the leak for the most cost-sensitive user segment.

          Independently, increasing the cache TTL from 5 minutes to something much longer (e.g., 1 hour) would reduce the frequency of probes. Subscription type and slash commands change very rarely, so a longer TTL has no practical downside.

          Files involved

          FileRole
          apps/server/src/provider/Layers/ClaudeProvider.tsprobeClaudeCapabilities() (line 494), cache setup (line 794), checkClaudeProviderStatus() guard (line 679)
          apps/server/src/provider/makeManagedServerProvider.ts60-second refresh loop (line 133)
          node_modules/@anthropic-ai/claude-agent-sdk/sdk.mjsE$$ (query), S$$ (startup), WH (transport setup), zH (prompt write), QX.initialize (maxTurns flag builder)

          Metadata

          Metadata

          Assignees

          No one assigned

            Labels

            bugSomething is broken or behaving incorrectly.needs-triageIssue needs maintainer review and initial categorization.

            Type

            No type

            Projects

            No projects

              Milestone

              No milestone

              Relationships

              None yet

              Development

              No branches or pull requests

              Issue actions

              , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
              Skip to content

              [Bug]: Nightly: Claude Code burns tokens every 5 minutes while t3code is running idle #2191

              Description

              @reasv

              Before submitting

              • I searched existing issues and did not find a duplicate.
              • I included enough detail to reproduce or investigate the problem.

              Area

              apps/web

              Steps to reproduce

              I'm using t3code nightly with npx t3@nightly on Linux, along with the latest version of Claude Code (2.1.114), configured with API (not subscription) through a local HAProxy instance that sits between CC and AWS in order to monitor requests.
              The issue starts as soon as I launch t3code via npx.

              Expected behavior

              Claude Code should not perform any API requests while t3code is running before any task has been issued.

              Actual behavior

              As soon as I start t3code, HAProxy immediately logs a POST /v1/messages?beta=true HTTP/1.1 request, and I can see that it has used up some tokens in the process, the amount varies, but it's usually over 10k.
              The user agent is always claude-cli/2.1.114 (external, sdk-ts, agent-sdk/0.2.111).
              This repeats every 5 minutes on the clock, and stops as soon as I kill t3code.

              This doesn't happen with Claude Code on its own, when eg. leaving the TUI open with an ongoing conversation session.

              The 5 minute cadence makes me think this is some kind of cache refresh. I don't store the actual requests, so I don't know what it was sending. This happens regardless of whether I ever open the WebUI.

              Impact

              Blocks work completely

              Version or commit

              0.0.21-nightly.20260417.58

              Environment

              Ubuntu 24.04

              Logs or stack traces

              From my HAProxy:
              172.20.0.1:37872 [18/Apr/2026:21:47:33.729] ... {claude-cli/2.1.114 ...} "POST /v1/messages?beta=true HTTP/1.1"
              172.20.0.1:60910 [18/Apr/2026:21:52:35.283] ... {claude-cli/2.1.114 ...} "POST /v1/messages?beta=true HTTP/1.1"
              172.20.0.1:37290 [18/Apr/2026:21:57:40.132] ... {claude-cli/2.1.114 ...} "POST /v1/messages?beta=true HTTP/1.1"

              Screenshots, recordings, or supporting files

              No response

              Workaround

              I asked Claude Code/Opus 4.6 to investigate this issue, and it came with a plausible explanation of the issue and a possible fix.
              I felt like I should file this bug ASAP so I haven't properly applied and tested its fix yet, but if it works for me and it seems reasonable to the maintainers I can come up with a PR.

              Everything after this line was produced by Claude:

              Summary

              When t3code is running with Claude Code configured, the server makes a real POST /v1/messages request to the Anthropic API every ~5 minutes, even with no browser open and no user interaction. Each request consumes tens of thousands of tokens because it sends the full Claude Code system prompt. Left running overnight, this silently drains significant API credit.

              The requests originate from probeClaudeCapabilities() in apps/server/src/provider/Layers/ClaudeProvider.ts. This function is intended to be a lightweight, zero-cost probe that reads local account metadata from a Claude Code subprocess without ever hitting the API. Due to two bugs -- one in t3code and one in the Claude Agent SDK -- it instead sends a real prompt (".") to the Anthropic messages endpoint every time it fires.

              Evidence

              HAProxy logs showing 5-minute-interval requests with no user activity (t3code started, browser never opened):

              172.20.0.1:37872 [18/Apr/2026:21:47:33.729] ... {claude-cli/2.1.114 ...} "POST /v1/messages?beta=true HTTP/1.1"
              172.20.0.1:60910 [18/Apr/2026:21:52:35.283] ... {claude-cli/2.1.114 ...} "POST /v1/messages?beta=true HTTP/1.1"
              172.20.0.1:37290 [18/Apr/2026:21:57:40.132] ... {claude-cli/2.1.114 ...} "POST /v1/messages?beta=true HTTP/1.1"
              

              The ~5-minute interval, the claude-cli user agent, and the ~1.4s response times are all consistent with this analysis.

              How the probe is supposed to work

              probeClaudeCapabilities() (line 494) exists to discover the user's subscription type and available slash commands when claude auth status does not include that information. It is called as a cache-miss handler behind an Effect Cache with a 5-minute TTL, which itself is consulted on every iteration of the 60-second provider health check loop.

              The intended flow, per the comment at line 483-491:

              The prompt is never sent to the Anthropic API -- we abort immediately after the local initialization phase completes. This gives us the user's subscription type without incurring any token cost.

              The code uses the SDK's query() function with maxTurns: 0 and an AbortController, expecting the subprocess to initialize locally (returning account info via IPC), then be killed before it ever calls the API:

              constprobeClaudeCapabilities=(binaryPath: string)=>{constabort=newAbortController();returnEffect.tryPromise(async()=>{constq=claudeQuery({prompt: ".",options: {maxTurns: 0,abortController: abort,persistSession: false,// ...},});constinit=awaitq.initializationResult();return{subscriptionType: init.account?.subscriptionType,slashCommands: parseClaudeInitializationCommands(init.commands),};}).pipe(Effect.ensuring(Effect.sync(()=>{if(!abort.signal.aborted)abort.abort();})),// ...);};

              Why it actually makes an API call

              Two bugs combine to defeat the intended behavior.

              Bug 1: maxTurns: 0 is silently ignored by the SDK

              Inside the Agent SDK (sdk.mjs), CLI arguments are built in the QX (ProcessTransport) constructor's initialize() method. The maxTurns option is handled as:

              if(K)p.push("--max-turns",K.toString());

              where K is the value of maxTurns. In JavaScript, 0 is falsy. if (0) evaluates to false, so --max-turns is never passed to the claude subprocess. The process starts with unlimited turns.

              This is a bug in @anthropic-ai/claude-agent-sdk. The check should be if (K != null) or if (K !== undefined) to allow an explicit zero value.

              Bug 2: The prompt is written to stdin before initializationResult() resolves

              The SDK's query() export (E$$ in the minified source) works like this:

              functionE$$({prompt, options}){// 1. Spawn the subprocess immediately (deferSpawn is undefined/falsy)let{queryInstance, transport, abortController}=WH(options,typeofprompt==="string");// 2. Write the prompt to subprocess stdin RIGHT NOW, synchronouslyzH(queryInstance,transport,prompt,abortController);// 3. Return the query handle (caller will await initializationResult() later)returnqueryInstance;}

              zH() serializes the user message and writes it to the subprocess's stdin pipe:

              functionzH(queryInstance,transport,prompt,abortController){if(typeofprompt==="string")transport.write(JSON.stringify({type: "user",session_id: "",message: {role: "user",content: [{type: "text",text: "."}]},parent_tool_use_id: null,})+"\n");}

              This means the prompt "." lands in the subprocess's stdin buffer beforeprobeClaudeCapabilities even begins awaiting initializationResult().

              The race the subprocess sees

              Subprocess starts
              |-- Reads stdin (stream-json mode)
              |-- Receives IPC {subtype: "initialize"} control message
              |-- Sends back init response (local file reads only, no API call)
              |-- Receives user message {role: "user", content: "."}
              |-- Begins processing user turn --> POST /v1/messages <-- THE LEAK
              | (full system prompt + tools + "." = tens of thousands of tokens)
              |
              `-- SIGTERM arrives (from abort.abort() in Effect.ensuring)
              ... but the HTTP request is already in flight or completed
              

              The initialization IPC round-trip completes in milliseconds (it only reads local files). But by then the prompt has been sitting in the stdin buffer the entire time. The subprocess processes messages in order, so it hits the user message almost immediately after responding to the initialize request. The abort fires only after initializationResult() resolves back in the Node.js event loop -- too late.

              When the probe fires

              The probe fires when both of these conditions are true:

              1. claude auth status JSON output does not contain a subscription type field. The current format is {"loggedIn": true, "authMethod": "..."} with no subscriptionType key, so this condition is met for all users.
              2. The 5-minute Effect Cache TTL has expired (or it is the first call).

              The 60-second health check loop hits Cache.get() every minute. The cache absorbs 4 out of every 5 calls, but on every 5th call (cache miss), probeClaudeCapabilities() fires and makes a real API request.

              Timeline:

              t=0s Server starts --> first probe fires (cache cold)
              t=60s Health check --> cache hit, no probe
              t=120s Health check --> cache hit
              t=180s Health check --> cache hit
              t=240s Health check --> cache hit
              t=300s Health check --> cache MISS (TTL expired) --> probe fires, API call
              t=360s Health check --> cache hit (just refreshed)
              ...
              t=600s Health check --> cache MISS --> probe fires, API call
              

              This matches the observed ~5-minute interval in the HAProxy logs.

              Impact

              • Token cost: Each probe sends the full Claude Code system prompt (tool definitions, project context, CLAUDE.md, etc.) plus the "." user message. This is tens of thousands of input tokens per request, plus a small model response.
              • Recurring: Fires every 5 minutes, 24/7, as long as t3code's server process is running.
              • Silent: No UI indication. No browser needs to be open. No user interaction required.
              • Unavoidable: Fires for any user with Claude Code configured as a provider, regardless of whether they are actively using it.

              An overnight session (~8 hours) would make ~96 API requests, consuming potentially millions of tokens for zero value.

              Suggested fix

              Primary fix: use startup() instead of query()

              The SDK exports a startup() function (exported as S$$ / startup in sdk.mjs) that is explicitly designed for the pre-warm / probe use case. It:

              1. Spawns the subprocess
              2. Awaits initializationResult() internally
              3. Returns a WarmQuery handle with .query() and .close() methods
              4. Does NOT write any prompt to stdin until .query() is explicitly called

              If you call .close() without ever calling .query(), no prompt is ever sent and no API call is made. This is exactly the behavior probeClaudeCapabilities needs:

              import{startup}from"@anthropic-ai/claude-agent-sdk";constprobeClaudeCapabilities=(binaryPath: string)=>{returnEffect.tryPromise(async()=>{constwarm=awaitstartup({options: {persistSession: false,pathToClaudeCodeExecutable: binaryPath,settingSources: ["user","project","local"],allowedTools: [],stderr: ()=>{},},initializeTimeoutMs: CAPABILITIES_PROBE_TIMEOUT_MS,});try{// startup() has already awaited initializationResult() internally.// Access the init data through the underlying query instance to// extract subscriptionType and slash commands.// (The exact access pattern depends on whether the SDK exposes the// init data on the WarmQuery handle -- if not, this may need a// small SDK-side change to surface it.)}finally{warm.close();// No prompt was ever written. No API call. Zero tokens.}}).pipe(Effect.timeoutOption(CAPABILITIES_PROBE_TIMEOUT_MS),Effect.result,Effect.map((result)=>{if(Result.isFailure(result))returnundefined;returnOption.isSome(result.success) ? result.success.value : undefined;}),);};

              Note: startup() awaits initializationResult() internally but does not directly expose the resolved init data on the returned WarmQuery handle. This means the fix may also require either:

              • A small change to the SDK to expose initializationResult data on the WarmQuery return value, or
              • Accessing the underlying query instance's already-resolved initialization promise (it was awaited inside startup(), so re-awaiting it is synchronous).

              Secondary fix (SDK-side): handle maxTurns: 0 correctly

              The check in the SDK's process argument builder should be changed from:

              if(K)p.push("--max-turns",K.toString());

              to:

              if(K!=null)p.push("--max-turns",K.toString());

              This would allow maxTurns: 0 to actually pass --max-turns 0 to the CLI, which would be a correct safety net even when using query(). However, this alone would not fully fix the issue because the prompt is still written to stdin before initialization completes -- the subprocess would still receive the user message, it would just (hopefully) refuse to process it due to the turn limit. The startup() approach is more robust because it avoids writing the prompt entirely.

              Additional hardening: increase cache TTL or skip probe for API key users

              For users authenticated via API key (authMethod: "api-key"), the subscription type is not meaningful (they are billed per-token regardless). The probe could be skipped entirely for these users, since claude auth status already reports the auth method. This would eliminate the leak for the most cost-sensitive user segment.

              Independently, increasing the cache TTL from 5 minutes to something much longer (e.g., 1 hour) would reduce the frequency of probes. Subscription type and slash commands change very rarely, so a longer TTL has no practical downside.

              Files involved

              FileRole
              apps/server/src/provider/Layers/ClaudeProvider.tsprobeClaudeCapabilities() (line 494), cache setup (line 794), checkClaudeProviderStatus() guard (line 679)
              apps/server/src/provider/makeManagedServerProvider.ts60-second refresh loop (line 133)
              node_modules/@anthropic-ai/claude-agent-sdk/sdk.mjsE$$ (query), S$$ (startup), WH (transport setup), zH (prompt write), QX.initialize (maxTurns flag builder)

              Metadata

              Metadata

              Assignees

              No one assigned

                Labels

                bugSomething is broken or behaving incorrectly.needs-triageIssue needs maintainer review and initial categorization.

                Type

                No type

                Projects

                No projects

                  Milestone

                  No milestone

                  Relationships

                  None yet

                  Development

                  No branches or pull requests

                  Issue actions

                  , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
                  Skip to content

                  [Bug]: Nightly: Claude Code burns tokens every 5 minutes while t3code is running idle #2191

                  Description

                  @reasv

                  Before submitting

                  • I searched existing issues and did not find a duplicate.
                  • I included enough detail to reproduce or investigate the problem.

                  Area

                  apps/web

                  Steps to reproduce

                  I'm using t3code nightly with npx t3@nightly on Linux, along with the latest version of Claude Code (2.1.114), configured with API (not subscription) through a local HAProxy instance that sits between CC and AWS in order to monitor requests.
                  The issue starts as soon as I launch t3code via npx.

                  Expected behavior

                  Claude Code should not perform any API requests while t3code is running before any task has been issued.

                  Actual behavior

                  As soon as I start t3code, HAProxy immediately logs a POST /v1/messages?beta=true HTTP/1.1 request, and I can see that it has used up some tokens in the process, the amount varies, but it's usually over 10k.
                  The user agent is always claude-cli/2.1.114 (external, sdk-ts, agent-sdk/0.2.111).
                  This repeats every 5 minutes on the clock, and stops as soon as I kill t3code.

                  This doesn't happen with Claude Code on its own, when eg. leaving the TUI open with an ongoing conversation session.

                  The 5 minute cadence makes me think this is some kind of cache refresh. I don't store the actual requests, so I don't know what it was sending. This happens regardless of whether I ever open the WebUI.

                  Impact

                  Blocks work completely

                  Version or commit

                  0.0.21-nightly.20260417.58

                  Environment

                  Ubuntu 24.04

                  Logs or stack traces

                  From my HAProxy:
                  172.20.0.1:37872 [18/Apr/2026:21:47:33.729] ... {claude-cli/2.1.114 ...} "POST /v1/messages?beta=true HTTP/1.1"
                  172.20.0.1:60910 [18/Apr/2026:21:52:35.283] ... {claude-cli/2.1.114 ...} "POST /v1/messages?beta=true HTTP/1.1"
                  172.20.0.1:37290 [18/Apr/2026:21:57:40.132] ... {claude-cli/2.1.114 ...} "POST /v1/messages?beta=true HTTP/1.1"

                  Screenshots, recordings, or supporting files

                  No response

                  Workaround

                  I asked Claude Code/Opus 4.6 to investigate this issue, and it came with a plausible explanation of the issue and a possible fix.
                  I felt like I should file this bug ASAP so I haven't properly applied and tested its fix yet, but if it works for me and it seems reasonable to the maintainers I can come up with a PR.

                  Everything after this line was produced by Claude:

                  Summary

                  When t3code is running with Claude Code configured, the server makes a real POST /v1/messages request to the Anthropic API every ~5 minutes, even with no browser open and no user interaction. Each request consumes tens of thousands of tokens because it sends the full Claude Code system prompt. Left running overnight, this silently drains significant API credit.

                  The requests originate from probeClaudeCapabilities() in apps/server/src/provider/Layers/ClaudeProvider.ts. This function is intended to be a lightweight, zero-cost probe that reads local account metadata from a Claude Code subprocess without ever hitting the API. Due to two bugs -- one in t3code and one in the Claude Agent SDK -- it instead sends a real prompt (".") to the Anthropic messages endpoint every time it fires.

                  Evidence

                  HAProxy logs showing 5-minute-interval requests with no user activity (t3code started, browser never opened):

                  172.20.0.1:37872 [18/Apr/2026:21:47:33.729] ... {claude-cli/2.1.114 ...} "POST /v1/messages?beta=true HTTP/1.1"
                  172.20.0.1:60910 [18/Apr/2026:21:52:35.283] ... {claude-cli/2.1.114 ...} "POST /v1/messages?beta=true HTTP/1.1"
                  172.20.0.1:37290 [18/Apr/2026:21:57:40.132] ... {claude-cli/2.1.114 ...} "POST /v1/messages?beta=true HTTP/1.1"
                  

                  The ~5-minute interval, the claude-cli user agent, and the ~1.4s response times are all consistent with this analysis.

                  How the probe is supposed to work

                  probeClaudeCapabilities() (line 494) exists to discover the user's subscription type and available slash commands when claude auth status does not include that information. It is called as a cache-miss handler behind an Effect Cache with a 5-minute TTL, which itself is consulted on every iteration of the 60-second provider health check loop.

                  The intended flow, per the comment at line 483-491:

                  The prompt is never sent to the Anthropic API -- we abort immediately after the local initialization phase completes. This gives us the user's subscription type without incurring any token cost.

                  The code uses the SDK's query() function with maxTurns: 0 and an AbortController, expecting the subprocess to initialize locally (returning account info via IPC), then be killed before it ever calls the API:

                  constprobeClaudeCapabilities=(binaryPath: string)=>{constabort=newAbortController();returnEffect.tryPromise(async()=>{constq=claudeQuery({prompt: ".",options: {maxTurns: 0,abortController: abort,persistSession: false,// ...},});constinit=awaitq.initializationResult();return{subscriptionType: init.account?.subscriptionType,slashCommands: parseClaudeInitializationCommands(init.commands),};}).pipe(Effect.ensuring(Effect.sync(()=>{if(!abort.signal.aborted)abort.abort();})),// ...);};

                  Why it actually makes an API call

                  Two bugs combine to defeat the intended behavior.

                  Bug 1: maxTurns: 0 is silently ignored by the SDK

                  Inside the Agent SDK (sdk.mjs), CLI arguments are built in the QX (ProcessTransport) constructor's initialize() method. The maxTurns option is handled as:

                  if(K)p.push("--max-turns",K.toString());

                  where K is the value of maxTurns. In JavaScript, 0 is falsy. if (0) evaluates to false, so --max-turns is never passed to the claude subprocess. The process starts with unlimited turns.

                  This is a bug in @anthropic-ai/claude-agent-sdk. The check should be if (K != null) or if (K !== undefined) to allow an explicit zero value.

                  Bug 2: The prompt is written to stdin before initializationResult() resolves

                  The SDK's query() export (E$$ in the minified source) works like this:

                  functionE$$({prompt, options}){// 1. Spawn the subprocess immediately (deferSpawn is undefined/falsy)let{queryInstance, transport, abortController}=WH(options,typeofprompt==="string");// 2. Write the prompt to subprocess stdin RIGHT NOW, synchronouslyzH(queryInstance,transport,prompt,abortController);// 3. Return the query handle (caller will await initializationResult() later)returnqueryInstance;}

                  zH() serializes the user message and writes it to the subprocess's stdin pipe:

                  functionzH(queryInstance,transport,prompt,abortController){if(typeofprompt==="string")transport.write(JSON.stringify({type: "user",session_id: "",message: {role: "user",content: [{type: "text",text: "."}]},parent_tool_use_id: null,})+"\n");}

                  This means the prompt "." lands in the subprocess's stdin buffer beforeprobeClaudeCapabilities even begins awaiting initializationResult().

                  The race the subprocess sees

                  Subprocess starts
                  |-- Reads stdin (stream-json mode)
                  |-- Receives IPC {subtype: "initialize"} control message
                  |-- Sends back init response (local file reads only, no API call)
                  |-- Receives user message {role: "user", content: "."}
                  |-- Begins processing user turn --> POST /v1/messages <-- THE LEAK
                  | (full system prompt + tools + "." = tens of thousands of tokens)
                  |
                  `-- SIGTERM arrives (from abort.abort() in Effect.ensuring)
                  ... but the HTTP request is already in flight or completed
                  

                  The initialization IPC round-trip completes in milliseconds (it only reads local files). But by then the prompt has been sitting in the stdin buffer the entire time. The subprocess processes messages in order, so it hits the user message almost immediately after responding to the initialize request. The abort fires only after initializationResult() resolves back in the Node.js event loop -- too late.

                  When the probe fires

                  The probe fires when both of these conditions are true:

                  1. claude auth status JSON output does not contain a subscription type field. The current format is {"loggedIn": true, "authMethod": "..."} with no subscriptionType key, so this condition is met for all users.
                  2. The 5-minute Effect Cache TTL has expired (or it is the first call).

                  The 60-second health check loop hits Cache.get() every minute. The cache absorbs 4 out of every 5 calls, but on every 5th call (cache miss), probeClaudeCapabilities() fires and makes a real API request.

                  Timeline:

                  t=0s Server starts --> first probe fires (cache cold)
                  t=60s Health check --> cache hit, no probe
                  t=120s Health check --> cache hit
                  t=180s Health check --> cache hit
                  t=240s Health check --> cache hit
                  t=300s Health check --> cache MISS (TTL expired) --> probe fires, API call
                  t=360s Health check --> cache hit (just refreshed)
                  ...
                  t=600s Health check --> cache MISS --> probe fires, API call
                  

                  This matches the observed ~5-minute interval in the HAProxy logs.

                  Impact

                  • Token cost: Each probe sends the full Claude Code system prompt (tool definitions, project context, CLAUDE.md, etc.) plus the "." user message. This is tens of thousands of input tokens per request, plus a small model response.
                  • Recurring: Fires every 5 minutes, 24/7, as long as t3code's server process is running.
                  • Silent: No UI indication. No browser needs to be open. No user interaction required.
                  • Unavoidable: Fires for any user with Claude Code configured as a provider, regardless of whether they are actively using it.

                  An overnight session (~8 hours) would make ~96 API requests, consuming potentially millions of tokens for zero value.

                  Suggested fix

                  Primary fix: use startup() instead of query()

                  The SDK exports a startup() function (exported as S$$ / startup in sdk.mjs) that is explicitly designed for the pre-warm / probe use case. It:

                  1. Spawns the subprocess
                  2. Awaits initializationResult() internally
                  3. Returns a WarmQuery handle with .query() and .close() methods
                  4. Does NOT write any prompt to stdin until .query() is explicitly called

                  If you call .close() without ever calling .query(), no prompt is ever sent and no API call is made. This is exactly the behavior probeClaudeCapabilities needs:

                  import{startup}from"@anthropic-ai/claude-agent-sdk";constprobeClaudeCapabilities=(binaryPath: string)=>{returnEffect.tryPromise(async()=>{constwarm=awaitstartup({options: {persistSession: false,pathToClaudeCodeExecutable: binaryPath,settingSources: ["user","project","local"],allowedTools: [],stderr: ()=>{},},initializeTimeoutMs: CAPABILITIES_PROBE_TIMEOUT_MS,});try{// startup() has already awaited initializationResult() internally.// Access the init data through the underlying query instance to// extract subscriptionType and slash commands.// (The exact access pattern depends on whether the SDK exposes the// init data on the WarmQuery handle -- if not, this may need a// small SDK-side change to surface it.)}finally{warm.close();// No prompt was ever written. No API call. Zero tokens.}}).pipe(Effect.timeoutOption(CAPABILITIES_PROBE_TIMEOUT_MS),Effect.result,Effect.map((result)=>{if(Result.isFailure(result))returnundefined;returnOption.isSome(result.success) ? result.success.value : undefined;}),);};

                  Note: startup() awaits initializationResult() internally but does not directly expose the resolved init data on the returned WarmQuery handle. This means the fix may also require either:

                  • A small change to the SDK to expose initializationResult data on the WarmQuery return value, or
                  • Accessing the underlying query instance's already-resolved initialization promise (it was awaited inside startup(), so re-awaiting it is synchronous).

                  Secondary fix (SDK-side): handle maxTurns: 0 correctly

                  The check in the SDK's process argument builder should be changed from:

                  if(K)p.push("--max-turns",K.toString());

                  to:

                  if(K!=null)p.push("--max-turns",K.toString());

                  This would allow maxTurns: 0 to actually pass --max-turns 0 to the CLI, which would be a correct safety net even when using query(). However, this alone would not fully fix the issue because the prompt is still written to stdin before initialization completes -- the subprocess would still receive the user message, it would just (hopefully) refuse to process it due to the turn limit. The startup() approach is more robust because it avoids writing the prompt entirely.

                  Additional hardening: increase cache TTL or skip probe for API key users

                  For users authenticated via API key (authMethod: "api-key"), the subscription type is not meaningful (they are billed per-token regardless). The probe could be skipped entirely for these users, since claude auth status already reports the auth method. This would eliminate the leak for the most cost-sensitive user segment.

                  Independently, increasing the cache TTL from 5 minutes to something much longer (e.g., 1 hour) would reduce the frequency of probes. Subscription type and slash commands change very rarely, so a longer TTL has no practical downside.

                  Files involved

                  FileRole
                  apps/server/src/provider/Layers/ClaudeProvider.tsprobeClaudeCapabilities() (line 494), cache setup (line 794), checkClaudeProviderStatus() guard (line 679)
                  apps/server/src/provider/makeManagedServerProvider.ts60-second refresh loop (line 133)
                  node_modules/@anthropic-ai/claude-agent-sdk/sdk.mjsE$$ (query), S$$ (startup), WH (transport setup), zH (prompt write), QX.initialize (maxTurns flag builder)

                  Metadata

                  Metadata

                  Assignees

                  No one assigned

                    Labels

                    bugSomething is broken or behaving incorrectly.needs-triageIssue needs maintainer review and initial categorization.

                    Type

                    No type

                    Projects

                    No projects

                      Milestone

                      No milestone

                      Relationships

                      None yet

                      Development

                      No branches or pull requests

                      Issue actions

                      , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
                      Skip to content

                      [Bug]: Nightly: Claude Code burns tokens every 5 minutes while t3code is running idle #2191

                      Description

                      @reasv

                      Before submitting

                      • I searched existing issues and did not find a duplicate.
                      • I included enough detail to reproduce or investigate the problem.

                      Area

                      apps/web

                      Steps to reproduce

                      I'm using t3code nightly with npx t3@nightly on Linux, along with the latest version of Claude Code (2.1.114), configured with API (not subscription) through a local HAProxy instance that sits between CC and AWS in order to monitor requests.
                      The issue starts as soon as I launch t3code via npx.

                      Expected behavior

                      Claude Code should not perform any API requests while t3code is running before any task has been issued.

                      Actual behavior

                      As soon as I start t3code, HAProxy immediately logs a POST /v1/messages?beta=true HTTP/1.1 request, and I can see that it has used up some tokens in the process, the amount varies, but it's usually over 10k.
                      The user agent is always claude-cli/2.1.114 (external, sdk-ts, agent-sdk/0.2.111).
                      This repeats every 5 minutes on the clock, and stops as soon as I kill t3code.

                      This doesn't happen with Claude Code on its own, when eg. leaving the TUI open with an ongoing conversation session.

                      The 5 minute cadence makes me think this is some kind of cache refresh. I don't store the actual requests, so I don't know what it was sending. This happens regardless of whether I ever open the WebUI.

                      Impact

                      Blocks work completely

                      Version or commit

                      0.0.21-nightly.20260417.58

                      Environment

                      Ubuntu 24.04

                      Logs or stack traces

                      From my HAProxy:
                      172.20.0.1:37872 [18/Apr/2026:21:47:33.729] ... {claude-cli/2.1.114 ...} "POST /v1/messages?beta=true HTTP/1.1"
                      172.20.0.1:60910 [18/Apr/2026:21:52:35.283] ... {claude-cli/2.1.114 ...} "POST /v1/messages?beta=true HTTP/1.1"
                      172.20.0.1:37290 [18/Apr/2026:21:57:40.132] ... {claude-cli/2.1.114 ...} "POST /v1/messages?beta=true HTTP/1.1"

                      Screenshots, recordings, or supporting files

                      No response

                      Workaround

                      I asked Claude Code/Opus 4.6 to investigate this issue, and it came with a plausible explanation of the issue and a possible fix.
                      I felt like I should file this bug ASAP so I haven't properly applied and tested its fix yet, but if it works for me and it seems reasonable to the maintainers I can come up with a PR.

                      Everything after this line was produced by Claude:

                      Summary

                      When t3code is running with Claude Code configured, the server makes a real POST /v1/messages request to the Anthropic API every ~5 minutes, even with no browser open and no user interaction. Each request consumes tens of thousands of tokens because it sends the full Claude Code system prompt. Left running overnight, this silently drains significant API credit.

                      The requests originate from probeClaudeCapabilities() in apps/server/src/provider/Layers/ClaudeProvider.ts. This function is intended to be a lightweight, zero-cost probe that reads local account metadata from a Claude Code subprocess without ever hitting the API. Due to two bugs -- one in t3code and one in the Claude Agent SDK -- it instead sends a real prompt (".") to the Anthropic messages endpoint every time it fires.

                      Evidence

                      HAProxy logs showing 5-minute-interval requests with no user activity (t3code started, browser never opened):

                      172.20.0.1:37872 [18/Apr/2026:21:47:33.729] ... {claude-cli/2.1.114 ...} "POST /v1/messages?beta=true HTTP/1.1"
                      172.20.0.1:60910 [18/Apr/2026:21:52:35.283] ... {claude-cli/2.1.114 ...} "POST /v1/messages?beta=true HTTP/1.1"
                      172.20.0.1:37290 [18/Apr/2026:21:57:40.132] ... {claude-cli/2.1.114 ...} "POST /v1/messages?beta=true HTTP/1.1"
                      

                      The ~5-minute interval, the claude-cli user agent, and the ~1.4s response times are all consistent with this analysis.

                      How the probe is supposed to work

                      probeClaudeCapabilities() (line 494) exists to discover the user's subscription type and available slash commands when claude auth status does not include that information. It is called as a cache-miss handler behind an Effect Cache with a 5-minute TTL, which itself is consulted on every iteration of the 60-second provider health check loop.

                      The intended flow, per the comment at line 483-491:

                      The prompt is never sent to the Anthropic API -- we abort immediately after the local initialization phase completes. This gives us the user's subscription type without incurring any token cost.

                      The code uses the SDK's query() function with maxTurns: 0 and an AbortController, expecting the subprocess to initialize locally (returning account info via IPC), then be killed before it ever calls the API:

                      constprobeClaudeCapabilities=(binaryPath: string)=>{constabort=newAbortController();returnEffect.tryPromise(async()=>{constq=claudeQuery({prompt: ".",options: {maxTurns: 0,abortController: abort,persistSession: false,// ...},});constinit=awaitq.initializationResult();return{subscriptionType: init.account?.subscriptionType,slashCommands: parseClaudeInitializationCommands(init.commands),};}).pipe(Effect.ensuring(Effect.sync(()=>{if(!abort.signal.aborted)abort.abort();})),// ...);};

                      Why it actually makes an API call

                      Two bugs combine to defeat the intended behavior.

                      Bug 1: maxTurns: 0 is silently ignored by the SDK

                      Inside the Agent SDK (sdk.mjs), CLI arguments are built in the QX (ProcessTransport) constructor's initialize() method. The maxTurns option is handled as:

                      if(K)p.push("--max-turns",K.toString());

                      where K is the value of maxTurns. In JavaScript, 0 is falsy. if (0) evaluates to false, so --max-turns is never passed to the claude subprocess. The process starts with unlimited turns.

                      This is a bug in @anthropic-ai/claude-agent-sdk. The check should be if (K != null) or if (K !== undefined) to allow an explicit zero value.

                      Bug 2: The prompt is written to stdin before initializationResult() resolves

                      The SDK's query() export (E$$ in the minified source) works like this:

                      functionE$$({prompt, options}){// 1. Spawn the subprocess immediately (deferSpawn is undefined/falsy)let{queryInstance, transport, abortController}=WH(options,typeofprompt==="string");// 2. Write the prompt to subprocess stdin RIGHT NOW, synchronouslyzH(queryInstance,transport,prompt,abortController);// 3. Return the query handle (caller will await initializationResult() later)returnqueryInstance;}

                      zH() serializes the user message and writes it to the subprocess's stdin pipe:

                      functionzH(queryInstance,transport,prompt,abortController){if(typeofprompt==="string")transport.write(JSON.stringify({type: "user",session_id: "",message: {role: "user",content: [{type: "text",text: "."}]},parent_tool_use_id: null,})+"\n");}

                      This means the prompt "." lands in the subprocess's stdin buffer beforeprobeClaudeCapabilities even begins awaiting initializationResult().

                      The race the subprocess sees

                      Subprocess starts
                      |-- Reads stdin (stream-json mode)
                      |-- Receives IPC {subtype: "initialize"} control message
                      |-- Sends back init response (local file reads only, no API call)
                      |-- Receives user message {role: "user", content: "."}
                      |-- Begins processing user turn --> POST /v1/messages <-- THE LEAK
                      | (full system prompt + tools + "." = tens of thousands of tokens)
                      |
                      `-- SIGTERM arrives (from abort.abort() in Effect.ensuring)
                      ... but the HTTP request is already in flight or completed
                      

                      The initialization IPC round-trip completes in milliseconds (it only reads local files). But by then the prompt has been sitting in the stdin buffer the entire time. The subprocess processes messages in order, so it hits the user message almost immediately after responding to the initialize request. The abort fires only after initializationResult() resolves back in the Node.js event loop -- too late.

                      When the probe fires

                      The probe fires when both of these conditions are true:

                      1. claude auth status JSON output does not contain a subscription type field. The current format is {"loggedIn": true, "authMethod": "..."} with no subscriptionType key, so this condition is met for all users.
                      2. The 5-minute Effect Cache TTL has expired (or it is the first call).

                      The 60-second health check loop hits Cache.get() every minute. The cache absorbs 4 out of every 5 calls, but on every 5th call (cache miss), probeClaudeCapabilities() fires and makes a real API request.

                      Timeline:

                      t=0s Server starts --> first probe fires (cache cold)
                      t=60s Health check --> cache hit, no probe
                      t=120s Health check --> cache hit
                      t=180s Health check --> cache hit
                      t=240s Health check --> cache hit
                      t=300s Health check --> cache MISS (TTL expired) --> probe fires, API call
                      t=360s Health check --> cache hit (just refreshed)
                      ...
                      t=600s Health check --> cache MISS --> probe fires, API call
                      

                      This matches the observed ~5-minute interval in the HAProxy logs.

                      Impact

                      • Token cost: Each probe sends the full Claude Code system prompt (tool definitions, project context, CLAUDE.md, etc.) plus the "." user message. This is tens of thousands of input tokens per request, plus a small model response.
                      • Recurring: Fires every 5 minutes, 24/7, as long as t3code's server process is running.
                      • Silent: No UI indication. No browser needs to be open. No user interaction required.
                      • Unavoidable: Fires for any user with Claude Code configured as a provider, regardless of whether they are actively using it.

                      An overnight session (~8 hours) would make ~96 API requests, consuming potentially millions of tokens for zero value.

                      Suggested fix

                      Primary fix: use startup() instead of query()

                      The SDK exports a startup() function (exported as S$$ / startup in sdk.mjs) that is explicitly designed for the pre-warm / probe use case. It:

                      1. Spawns the subprocess
                      2. Awaits initializationResult() internally
                      3. Returns a WarmQuery handle with .query() and .close() methods
                      4. Does NOT write any prompt to stdin until .query() is explicitly called

                      If you call .close() without ever calling .query(), no prompt is ever sent and no API call is made. This is exactly the behavior probeClaudeCapabilities needs:

                      import{startup}from"@anthropic-ai/claude-agent-sdk";constprobeClaudeCapabilities=(binaryPath: string)=>{returnEffect.tryPromise(async()=>{constwarm=awaitstartup({options: {persistSession: false,pathToClaudeCodeExecutable: binaryPath,settingSources: ["user","project","local"],allowedTools: [],stderr: ()=>{},},initializeTimeoutMs: CAPABILITIES_PROBE_TIMEOUT_MS,});try{// startup() has already awaited initializationResult() internally.// Access the init data through the underlying query instance to// extract subscriptionType and slash commands.// (The exact access pattern depends on whether the SDK exposes the// init data on the WarmQuery handle -- if not, this may need a// small SDK-side change to surface it.)}finally{warm.close();// No prompt was ever written. No API call. Zero tokens.}}).pipe(Effect.timeoutOption(CAPABILITIES_PROBE_TIMEOUT_MS),Effect.result,Effect.map((result)=>{if(Result.isFailure(result))returnundefined;returnOption.isSome(result.success) ? result.success.value : undefined;}),);};

                      Note: startup() awaits initializationResult() internally but does not directly expose the resolved init data on the returned WarmQuery handle. This means the fix may also require either:

                      • A small change to the SDK to expose initializationResult data on the WarmQuery return value, or
                      • Accessing the underlying query instance's already-resolved initialization promise (it was awaited inside startup(), so re-awaiting it is synchronous).

                      Secondary fix (SDK-side): handle maxTurns: 0 correctly

                      The check in the SDK's process argument builder should be changed from:

                      if(K)p.push("--max-turns",K.toString());

                      to:

                      if(K!=null)p.push("--max-turns",K.toString());

                      This would allow maxTurns: 0 to actually pass --max-turns 0 to the CLI, which would be a correct safety net even when using query(). However, this alone would not fully fix the issue because the prompt is still written to stdin before initialization completes -- the subprocess would still receive the user message, it would just (hopefully) refuse to process it due to the turn limit. The startup() approach is more robust because it avoids writing the prompt entirely.

                      Additional hardening: increase cache TTL or skip probe for API key users

                      For users authenticated via API key (authMethod: "api-key"), the subscription type is not meaningful (they are billed per-token regardless). The probe could be skipped entirely for these users, since claude auth status already reports the auth method. This would eliminate the leak for the most cost-sensitive user segment.

                      Independently, increasing the cache TTL from 5 minutes to something much longer (e.g., 1 hour) would reduce the frequency of probes. Subscription type and slash commands change very rarely, so a longer TTL has no practical downside.

                      Files involved

                      FileRole
                      apps/server/src/provider/Layers/ClaudeProvider.tsprobeClaudeCapabilities() (line 494), cache setup (line 794), checkClaudeProviderStatus() guard (line 679)
                      apps/server/src/provider/makeManagedServerProvider.ts60-second refresh loop (line 133)
                      node_modules/@anthropic-ai/claude-agent-sdk/sdk.mjsE$$ (query), S$$ (startup), WH (transport setup), zH (prompt write), QX.initialize (maxTurns flag builder)

                      Metadata

                      Metadata

                      Assignees

                      No one assigned

                        Labels

                        bugSomething is broken or behaving incorrectly.needs-triageIssue needs maintainer review and initial categorization.

                        Type

                        No type

                        Projects

                        No projects

                          Milestone

                          No milestone

                          Relationships

                          None yet

                          Development

                          No branches or pull requests

                          Issue actions

                          , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
                          Skip to content

                          [Bug]: Nightly: Claude Code burns tokens every 5 minutes while t3code is running idle #2191

                          Description

                          @reasv

                          Before submitting

                          • I searched existing issues and did not find a duplicate.
                          • I included enough detail to reproduce or investigate the problem.

                          Area

                          apps/web

                          Steps to reproduce

                          I'm using t3code nightly with npx t3@nightly on Linux, along with the latest version of Claude Code (2.1.114), configured with API (not subscription) through a local HAProxy instance that sits between CC and AWS in order to monitor requests.
                          The issue starts as soon as I launch t3code via npx.

                          Expected behavior

                          Claude Code should not perform any API requests while t3code is running before any task has been issued.

                          Actual behavior

                          As soon as I start t3code, HAProxy immediately logs a POST /v1/messages?beta=true HTTP/1.1 request, and I can see that it has used up some tokens in the process, the amount varies, but it's usually over 10k.
                          The user agent is always claude-cli/2.1.114 (external, sdk-ts, agent-sdk/0.2.111).
                          This repeats every 5 minutes on the clock, and stops as soon as I kill t3code.

                          This doesn't happen with Claude Code on its own, when eg. leaving the TUI open with an ongoing conversation session.

                          The 5 minute cadence makes me think this is some kind of cache refresh. I don't store the actual requests, so I don't know what it was sending. This happens regardless of whether I ever open the WebUI.

                          Impact

                          Blocks work completely

                          Version or commit

                          0.0.21-nightly.20260417.58

                          Environment

                          Ubuntu 24.04

                          Logs or stack traces

                          From my HAProxy:
                          172.20.0.1:37872 [18/Apr/2026:21:47:33.729] ... {claude-cli/2.1.114 ...} "POST /v1/messages?beta=true HTTP/1.1"
                          172.20.0.1:60910 [18/Apr/2026:21:52:35.283] ... {claude-cli/2.1.114 ...} "POST /v1/messages?beta=true HTTP/1.1"
                          172.20.0.1:37290 [18/Apr/2026:21:57:40.132] ... {claude-cli/2.1.114 ...} "POST /v1/messages?beta=true HTTP/1.1"

                          Screenshots, recordings, or supporting files

                          No response

                          Workaround

                          I asked Claude Code/Opus 4.6 to investigate this issue, and it came with a plausible explanation of the issue and a possible fix.
                          I felt like I should file this bug ASAP so I haven't properly applied and tested its fix yet, but if it works for me and it seems reasonable to the maintainers I can come up with a PR.

                          Everything after this line was produced by Claude:

                          Summary

                          When t3code is running with Claude Code configured, the server makes a real POST /v1/messages request to the Anthropic API every ~5 minutes, even with no browser open and no user interaction. Each request consumes tens of thousands of tokens because it sends the full Claude Code system prompt. Left running overnight, this silently drains significant API credit.

                          The requests originate from probeClaudeCapabilities() in apps/server/src/provider/Layers/ClaudeProvider.ts. This function is intended to be a lightweight, zero-cost probe that reads local account metadata from a Claude Code subprocess without ever hitting the API. Due to two bugs -- one in t3code and one in the Claude Agent SDK -- it instead sends a real prompt (".") to the Anthropic messages endpoint every time it fires.

                          Evidence

                          HAProxy logs showing 5-minute-interval requests with no user activity (t3code started, browser never opened):

                          172.20.0.1:37872 [18/Apr/2026:21:47:33.729] ... {claude-cli/2.1.114 ...} "POST /v1/messages?beta=true HTTP/1.1"
                          172.20.0.1:60910 [18/Apr/2026:21:52:35.283] ... {claude-cli/2.1.114 ...} "POST /v1/messages?beta=true HTTP/1.1"
                          172.20.0.1:37290 [18/Apr/2026:21:57:40.132] ... {claude-cli/2.1.114 ...} "POST /v1/messages?beta=true HTTP/1.1"
                          

                          The ~5-minute interval, the claude-cli user agent, and the ~1.4s response times are all consistent with this analysis.

                          How the probe is supposed to work

                          probeClaudeCapabilities() (line 494) exists to discover the user's subscription type and available slash commands when claude auth status does not include that information. It is called as a cache-miss handler behind an Effect Cache with a 5-minute TTL, which itself is consulted on every iteration of the 60-second provider health check loop.

                          The intended flow, per the comment at line 483-491:

                          The prompt is never sent to the Anthropic API -- we abort immediately after the local initialization phase completes. This gives us the user's subscription type without incurring any token cost.

                          The code uses the SDK's query() function with maxTurns: 0 and an AbortController, expecting the subprocess to initialize locally (returning account info via IPC), then be killed before it ever calls the API:

                          constprobeClaudeCapabilities=(binaryPath: string)=>{constabort=newAbortController();returnEffect.tryPromise(async()=>{constq=claudeQuery({prompt: ".",options: {maxTurns: 0,abortController: abort,persistSession: false,// ...},});constinit=awaitq.initializationResult();return{subscriptionType: init.account?.subscriptionType,slashCommands: parseClaudeInitializationCommands(init.commands),};}).pipe(Effect.ensuring(Effect.sync(()=>{if(!abort.signal.aborted)abort.abort();})),// ...);};

                          Why it actually makes an API call

                          Two bugs combine to defeat the intended behavior.

                          Bug 1: maxTurns: 0 is silently ignored by the SDK

                          Inside the Agent SDK (sdk.mjs), CLI arguments are built in the QX (ProcessTransport) constructor's initialize() method. The maxTurns option is handled as:

                          if(K)p.push("--max-turns",K.toString());

                          where K is the value of maxTurns. In JavaScript, 0 is falsy. if (0) evaluates to false, so --max-turns is never passed to the claude subprocess. The process starts with unlimited turns.

                          This is a bug in @anthropic-ai/claude-agent-sdk. The check should be if (K != null) or if (K !== undefined) to allow an explicit zero value.

                          Bug 2: The prompt is written to stdin before initializationResult() resolves

                          The SDK's query() export (E$$ in the minified source) works like this:

                          functionE$$({prompt, options}){// 1. Spawn the subprocess immediately (deferSpawn is undefined/falsy)let{queryInstance, transport, abortController}=WH(options,typeofprompt==="string");// 2. Write the prompt to subprocess stdin RIGHT NOW, synchronouslyzH(queryInstance,transport,prompt,abortController);// 3. Return the query handle (caller will await initializationResult() later)returnqueryInstance;}

                          zH() serializes the user message and writes it to the subprocess's stdin pipe:

                          functionzH(queryInstance,transport,prompt,abortController){if(typeofprompt==="string")transport.write(JSON.stringify({type: "user",session_id: "",message: {role: "user",content: [{type: "text",text: "."}]},parent_tool_use_id: null,})+"\n");}

                          This means the prompt "." lands in the subprocess's stdin buffer beforeprobeClaudeCapabilities even begins awaiting initializationResult().

                          The race the subprocess sees

                          Subprocess starts
                          |-- Reads stdin (stream-json mode)
                          |-- Receives IPC {subtype: "initialize"} control message
                          |-- Sends back init response (local file reads only, no API call)
                          |-- Receives user message {role: "user", content: "."}
                          |-- Begins processing user turn --> POST /v1/messages <-- THE LEAK
                          | (full system prompt + tools + "." = tens of thousands of tokens)
                          |
                          `-- SIGTERM arrives (from abort.abort() in Effect.ensuring)
                          ... but the HTTP request is already in flight or completed
                          

                          The initialization IPC round-trip completes in milliseconds (it only reads local files). But by then the prompt has been sitting in the stdin buffer the entire time. The subprocess processes messages in order, so it hits the user message almost immediately after responding to the initialize request. The abort fires only after initializationResult() resolves back in the Node.js event loop -- too late.

                          When the probe fires

                          The probe fires when both of these conditions are true:

                          1. claude auth status JSON output does not contain a subscription type field. The current format is {"loggedIn": true, "authMethod": "..."} with no subscriptionType key, so this condition is met for all users.
                          2. The 5-minute Effect Cache TTL has expired (or it is the first call).

                          The 60-second health check loop hits Cache.get() every minute. The cache absorbs 4 out of every 5 calls, but on every 5th call (cache miss), probeClaudeCapabilities() fires and makes a real API request.

                          Timeline:

                          t=0s Server starts --> first probe fires (cache cold)
                          t=60s Health check --> cache hit, no probe
                          t=120s Health check --> cache hit
                          t=180s Health check --> cache hit
                          t=240s Health check --> cache hit
                          t=300s Health check --> cache MISS (TTL expired) --> probe fires, API call
                          t=360s Health check --> cache hit (just refreshed)
                          ...
                          t=600s Health check --> cache MISS --> probe fires, API call
                          

                          This matches the observed ~5-minute interval in the HAProxy logs.

                          Impact

                          • Token cost: Each probe sends the full Claude Code system prompt (tool definitions, project context, CLAUDE.md, etc.) plus the "." user message. This is tens of thousands of input tokens per request, plus a small model response.
                          • Recurring: Fires every 5 minutes, 24/7, as long as t3code's server process is running.
                          • Silent: No UI indication. No browser needs to be open. No user interaction required.
                          • Unavoidable: Fires for any user with Claude Code configured as a provider, regardless of whether they are actively using it.

                          An overnight session (~8 hours) would make ~96 API requests, consuming potentially millions of tokens for zero value.

                          Suggested fix

                          Primary fix: use startup() instead of query()

                          The SDK exports a startup() function (exported as S$$ / startup in sdk.mjs) that is explicitly designed for the pre-warm / probe use case. It:

                          1. Spawns the subprocess
                          2. Awaits initializationResult() internally
                          3. Returns a WarmQuery handle with .query() and .close() methods
                          4. Does NOT write any prompt to stdin until .query() is explicitly called

                          If you call .close() without ever calling .query(), no prompt is ever sent and no API call is made. This is exactly the behavior probeClaudeCapabilities needs:

                          import{startup}from"@anthropic-ai/claude-agent-sdk";constprobeClaudeCapabilities=(binaryPath: string)=>{returnEffect.tryPromise(async()=>{constwarm=awaitstartup({options: {persistSession: false,pathToClaudeCodeExecutable: binaryPath,settingSources: ["user","project","local"],allowedTools: [],stderr: ()=>{},},initializeTimeoutMs: CAPABILITIES_PROBE_TIMEOUT_MS,});try{// startup() has already awaited initializationResult() internally.// Access the init data through the underlying query instance to// extract subscriptionType and slash commands.// (The exact access pattern depends on whether the SDK exposes the// init data on the WarmQuery handle -- if not, this may need a// small SDK-side change to surface it.)}finally{warm.close();// No prompt was ever written. No API call. Zero tokens.}}).pipe(Effect.timeoutOption(CAPABILITIES_PROBE_TIMEOUT_MS),Effect.result,Effect.map((result)=>{if(Result.isFailure(result))returnundefined;returnOption.isSome(result.success) ? result.success.value : undefined;}),);};

                          Note: startup() awaits initializationResult() internally but does not directly expose the resolved init data on the returned WarmQuery handle. This means the fix may also require either:

                          • A small change to the SDK to expose initializationResult data on the WarmQuery return value, or
                          • Accessing the underlying query instance's already-resolved initialization promise (it was awaited inside startup(), so re-awaiting it is synchronous).

                          Secondary fix (SDK-side): handle maxTurns: 0 correctly

                          The check in the SDK's process argument builder should be changed from:

                          if(K)p.push("--max-turns",K.toString());

                          to:

                          if(K!=null)p.push("--max-turns",K.toString());

                          This would allow maxTurns: 0 to actually pass --max-turns 0 to the CLI, which would be a correct safety net even when using query(). However, this alone would not fully fix the issue because the prompt is still written to stdin before initialization completes -- the subprocess would still receive the user message, it would just (hopefully) refuse to process it due to the turn limit. The startup() approach is more robust because it avoids writing the prompt entirely.

                          Additional hardening: increase cache TTL or skip probe for API key users

                          For users authenticated via API key (authMethod: "api-key"), the subscription type is not meaningful (they are billed per-token regardless). The probe could be skipped entirely for these users, since claude auth status already reports the auth method. This would eliminate the leak for the most cost-sensitive user segment.

                          Independently, increasing the cache TTL from 5 minutes to something much longer (e.g., 1 hour) would reduce the frequency of probes. Subscription type and slash commands change very rarely, so a longer TTL has no practical downside.

                          Files involved

                          FileRole
                          apps/server/src/provider/Layers/ClaudeProvider.tsprobeClaudeCapabilities() (line 494), cache setup (line 794), checkClaudeProviderStatus() guard (line 679)
                          apps/server/src/provider/makeManagedServerProvider.ts60-second refresh loop (line 133)
                          node_modules/@anthropic-ai/claude-agent-sdk/sdk.mjsE$$ (query), S$$ (startup), WH (transport setup), zH (prompt write), QX.initialize (maxTurns flag builder)

                          Metadata

                          Metadata

                          Assignees

                          No one assigned

                            Labels

                            bugSomething is broken or behaving incorrectly.needs-triageIssue needs maintainer review and initial categorization.

                            Type

                            No type

                            Projects

                            No projects

                              Milestone

                              No milestone

                              Relationships

                              None yet

                              Development

                              No branches or pull requests

                              Issue actions

                              , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
                              Skip to content

                              [Bug]: Nightly: Claude Code burns tokens every 5 minutes while t3code is running idle #2191

                              Description

                              @reasv

                              Before submitting

                              • I searched existing issues and did not find a duplicate.
                              • I included enough detail to reproduce or investigate the problem.

                              Area

                              apps/web

                              Steps to reproduce

                              I'm using t3code nightly with npx t3@nightly on Linux, along with the latest version of Claude Code (2.1.114), configured with API (not subscription) through a local HAProxy instance that sits between CC and AWS in order to monitor requests.
                              The issue starts as soon as I launch t3code via npx.

                              Expected behavior

                              Claude Code should not perform any API requests while t3code is running before any task has been issued.

                              Actual behavior

                              As soon as I start t3code, HAProxy immediately logs a POST /v1/messages?beta=true HTTP/1.1 request, and I can see that it has used up some tokens in the process, the amount varies, but it's usually over 10k.
                              The user agent is always claude-cli/2.1.114 (external, sdk-ts, agent-sdk/0.2.111).
                              This repeats every 5 minutes on the clock, and stops as soon as I kill t3code.

                              This doesn't happen with Claude Code on its own, when eg. leaving the TUI open with an ongoing conversation session.

                              The 5 minute cadence makes me think this is some kind of cache refresh. I don't store the actual requests, so I don't know what it was sending. This happens regardless of whether I ever open the WebUI.

                              Impact

                              Blocks work completely

                              Version or commit

                              0.0.21-nightly.20260417.58

                              Environment

                              Ubuntu 24.04

                              Logs or stack traces

                              From my HAProxy:
                              172.20.0.1:37872 [18/Apr/2026:21:47:33.729] ... {claude-cli/2.1.114 ...} "POST /v1/messages?beta=true HTTP/1.1"
                              172.20.0.1:60910 [18/Apr/2026:21:52:35.283] ... {claude-cli/2.1.114 ...} "POST /v1/messages?beta=true HTTP/1.1"
                              172.20.0.1:37290 [18/Apr/2026:21:57:40.132] ... {claude-cli/2.1.114 ...} "POST /v1/messages?beta=true HTTP/1.1"

                              Screenshots, recordings, or supporting files

                              No response

                              Workaround

                              I asked Claude Code/Opus 4.6 to investigate this issue, and it came with a plausible explanation of the issue and a possible fix.
                              I felt like I should file this bug ASAP so I haven't properly applied and tested its fix yet, but if it works for me and it seems reasonable to the maintainers I can come up with a PR.

                              Everything after this line was produced by Claude:

                              Summary

                              When t3code is running with Claude Code configured, the server makes a real POST /v1/messages request to the Anthropic API every ~5 minutes, even with no browser open and no user interaction. Each request consumes tens of thousands of tokens because it sends the full Claude Code system prompt. Left running overnight, this silently drains significant API credit.

                              The requests originate from probeClaudeCapabilities() in apps/server/src/provider/Layers/ClaudeProvider.ts. This function is intended to be a lightweight, zero-cost probe that reads local account metadata from a Claude Code subprocess without ever hitting the API. Due to two bugs -- one in t3code and one in the Claude Agent SDK -- it instead sends a real prompt (".") to the Anthropic messages endpoint every time it fires.

                              Evidence

                              HAProxy logs showing 5-minute-interval requests with no user activity (t3code started, browser never opened):

                              172.20.0.1:37872 [18/Apr/2026:21:47:33.729] ... {claude-cli/2.1.114 ...} "POST /v1/messages?beta=true HTTP/1.1"
                              172.20.0.1:60910 [18/Apr/2026:21:52:35.283] ... {claude-cli/2.1.114 ...} "POST /v1/messages?beta=true HTTP/1.1"
                              172.20.0.1:37290 [18/Apr/2026:21:57:40.132] ... {claude-cli/2.1.114 ...} "POST /v1/messages?beta=true HTTP/1.1"
                              

                              The ~5-minute interval, the claude-cli user agent, and the ~1.4s response times are all consistent with this analysis.

                              How the probe is supposed to work

                              probeClaudeCapabilities() (line 494) exists to discover the user's subscription type and available slash commands when claude auth status does not include that information. It is called as a cache-miss handler behind an Effect Cache with a 5-minute TTL, which itself is consulted on every iteration of the 60-second provider health check loop.

                              The intended flow, per the comment at line 483-491:

                              The prompt is never sent to the Anthropic API -- we abort immediately after the local initialization phase completes. This gives us the user's subscription type without incurring any token cost.

                              The code uses the SDK's query() function with maxTurns: 0 and an AbortController, expecting the subprocess to initialize locally (returning account info via IPC), then be killed before it ever calls the API:

                              constprobeClaudeCapabilities=(binaryPath: string)=>{constabort=newAbortController();returnEffect.tryPromise(async()=>{constq=claudeQuery({prompt: ".",options: {maxTurns: 0,abortController: abort,persistSession: false,// ...},});constinit=awaitq.initializationResult();return{subscriptionType: init.account?.subscriptionType,slashCommands: parseClaudeInitializationCommands(init.commands),};}).pipe(Effect.ensuring(Effect.sync(()=>{if(!abort.signal.aborted)abort.abort();})),// ...);};

                              Why it actually makes an API call

                              Two bugs combine to defeat the intended behavior.

                              Bug 1: maxTurns: 0 is silently ignored by the SDK

                              Inside the Agent SDK (sdk.mjs), CLI arguments are built in the QX (ProcessTransport) constructor's initialize() method. The maxTurns option is handled as:

                              if(K)p.push("--max-turns",K.toString());

                              where K is the value of maxTurns. In JavaScript, 0 is falsy. if (0) evaluates to false, so --max-turns is never passed to the claude subprocess. The process starts with unlimited turns.

                              This is a bug in @anthropic-ai/claude-agent-sdk. The check should be if (K != null) or if (K !== undefined) to allow an explicit zero value.

                              Bug 2: The prompt is written to stdin before initializationResult() resolves

                              The SDK's query() export (E$$ in the minified source) works like this:

                              functionE$$({prompt, options}){// 1. Spawn the subprocess immediately (deferSpawn is undefined/falsy)let{queryInstance, transport, abortController}=WH(options,typeofprompt==="string");// 2. Write the prompt to subprocess stdin RIGHT NOW, synchronouslyzH(queryInstance,transport,prompt,abortController);// 3. Return the query handle (caller will await initializationResult() later)returnqueryInstance;}

                              zH() serializes the user message and writes it to the subprocess's stdin pipe:

                              functionzH(queryInstance,transport,prompt,abortController){if(typeofprompt==="string")transport.write(JSON.stringify({type: "user",session_id: "",message: {role: "user",content: [{type: "text",text: "."}]},parent_tool_use_id: null,})+"\n");}

                              This means the prompt "." lands in the subprocess's stdin buffer beforeprobeClaudeCapabilities even begins awaiting initializationResult().

                              The race the subprocess sees

                              Subprocess starts
                              |-- Reads stdin (stream-json mode)
                              |-- Receives IPC {subtype: "initialize"} control message
                              |-- Sends back init response (local file reads only, no API call)
                              |-- Receives user message {role: "user", content: "."}
                              |-- Begins processing user turn --> POST /v1/messages <-- THE LEAK
                              | (full system prompt + tools + "." = tens of thousands of tokens)
                              |
                              `-- SIGTERM arrives (from abort.abort() in Effect.ensuring)
                              ... but the HTTP request is already in flight or completed
                              

                              The initialization IPC round-trip completes in milliseconds (it only reads local files). But by then the prompt has been sitting in the stdin buffer the entire time. The subprocess processes messages in order, so it hits the user message almost immediately after responding to the initialize request. The abort fires only after initializationResult() resolves back in the Node.js event loop -- too late.

                              When the probe fires

                              The probe fires when both of these conditions are true:

                              1. claude auth status JSON output does not contain a subscription type field. The current format is {"loggedIn": true, "authMethod": "..."} with no subscriptionType key, so this condition is met for all users.
                              2. The 5-minute Effect Cache TTL has expired (or it is the first call).

                              The 60-second health check loop hits Cache.get() every minute. The cache absorbs 4 out of every 5 calls, but on every 5th call (cache miss), probeClaudeCapabilities() fires and makes a real API request.

                              Timeline:

                              t=0s Server starts --> first probe fires (cache cold)
                              t=60s Health check --> cache hit, no probe
                              t=120s Health check --> cache hit
                              t=180s Health check --> cache hit
                              t=240s Health check --> cache hit
                              t=300s Health check --> cache MISS (TTL expired) --> probe fires, API call
                              t=360s Health check --> cache hit (just refreshed)
                              ...
                              t=600s Health check --> cache MISS --> probe fires, API call
                              

                              This matches the observed ~5-minute interval in the HAProxy logs.

                              Impact

                              • Token cost: Each probe sends the full Claude Code system prompt (tool definitions, project context, CLAUDE.md, etc.) plus the "." user message. This is tens of thousands of input tokens per request, plus a small model response.
                              • Recurring: Fires every 5 minutes, 24/7, as long as t3code's server process is running.
                              • Silent: No UI indication. No browser needs to be open. No user interaction required.
                              • Unavoidable: Fires for any user with Claude Code configured as a provider, regardless of whether they are actively using it.

                              An overnight session (~8 hours) would make ~96 API requests, consuming potentially millions of tokens for zero value.

                              Suggested fix

                              Primary fix: use startup() instead of query()

                              The SDK exports a startup() function (exported as S$$ / startup in sdk.mjs) that is explicitly designed for the pre-warm / probe use case. It:

                              1. Spawns the subprocess
                              2. Awaits initializationResult() internally
                              3. Returns a WarmQuery handle with .query() and .close() methods
                              4. Does NOT write any prompt to stdin until .query() is explicitly called

                              If you call .close() without ever calling .query(), no prompt is ever sent and no API call is made. This is exactly the behavior probeClaudeCapabilities needs:

                              import{startup}from"@anthropic-ai/claude-agent-sdk";constprobeClaudeCapabilities=(binaryPath: string)=>{returnEffect.tryPromise(async()=>{constwarm=awaitstartup({options: {persistSession: false,pathToClaudeCodeExecutable: binaryPath,settingSources: ["user","project","local"],allowedTools: [],stderr: ()=>{},},initializeTimeoutMs: CAPABILITIES_PROBE_TIMEOUT_MS,});try{// startup() has already awaited initializationResult() internally.// Access the init data through the underlying query instance to// extract subscriptionType and slash commands.// (The exact access pattern depends on whether the SDK exposes the// init data on the WarmQuery handle -- if not, this may need a// small SDK-side change to surface it.)}finally{warm.close();// No prompt was ever written. No API call. Zero tokens.}}).pipe(Effect.timeoutOption(CAPABILITIES_PROBE_TIMEOUT_MS),Effect.result,Effect.map((result)=>{if(Result.isFailure(result))returnundefined;returnOption.isSome(result.success) ? result.success.value : undefined;}),);};

                              Note: startup() awaits initializationResult() internally but does not directly expose the resolved init data on the returned WarmQuery handle. This means the fix may also require either:

                              • A small change to the SDK to expose initializationResult data on the WarmQuery return value, or
                              • Accessing the underlying query instance's already-resolved initialization promise (it was awaited inside startup(), so re-awaiting it is synchronous).

                              Secondary fix (SDK-side): handle maxTurns: 0 correctly

                              The check in the SDK's process argument builder should be changed from:

                              if(K)p.push("--max-turns",K.toString());

                              to:

                              if(K!=null)p.push("--max-turns",K.toString());

                              This would allow maxTurns: 0 to actually pass --max-turns 0 to the CLI, which would be a correct safety net even when using query(). However, this alone would not fully fix the issue because the prompt is still written to stdin before initialization completes -- the subprocess would still receive the user message, it would just (hopefully) refuse to process it due to the turn limit. The startup() approach is more robust because it avoids writing the prompt entirely.

                              Additional hardening: increase cache TTL or skip probe for API key users

                              For users authenticated via API key (authMethod: "api-key"), the subscription type is not meaningful (they are billed per-token regardless). The probe could be skipped entirely for these users, since claude auth status already reports the auth method. This would eliminate the leak for the most cost-sensitive user segment.

                              Independently, increasing the cache TTL from 5 minutes to something much longer (e.g., 1 hour) would reduce the frequency of probes. Subscription type and slash commands change very rarely, so a longer TTL has no practical downside.

                              Files involved

                              FileRole
                              apps/server/src/provider/Layers/ClaudeProvider.tsprobeClaudeCapabilities() (line 494), cache setup (line 794), checkClaudeProviderStatus() guard (line 679)
                              apps/server/src/provider/makeManagedServerProvider.ts60-second refresh loop (line 133)
                              node_modules/@anthropic-ai/claude-agent-sdk/sdk.mjsE$$ (query), S$$ (startup), WH (transport setup), zH (prompt write), QX.initialize (maxTurns flag builder)

                              Metadata

                              Metadata

                              Assignees

                              No one assigned

                                Labels

                                bugSomething is broken or behaving incorrectly.needs-triageIssue needs maintainer review and initial categorization.

                                Type

                                No type

                                Projects

                                No projects

                                  Milestone

                                  No milestone

                                  Relationships

                                  None yet

                                  Development

                                  No branches or pull requests

                                  Issue actions