Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,8 @@ signal, not a code-quality or productivity score.

Manage monthly budgets, model pricing, language signals, native preferences,
and local read-only connections for Codex and Claude. Software update checks
and automatic installation are separate settings; both are on by default.
and automatic installation are separate settings; checks are on by default,
and automatic installation is off.

<p align="center">
<img src="images/mcp.png" alt="Token Meter Settings view for local read-only agent connections" width="900">
Expand Down Expand Up @@ -188,8 +189,8 @@ content, prompts, responses, reasoning text, tool payloads, or trace paths.
Use the macOS menu bar, Linux tray, or beta Windows extension to reach the
current or pinned run. The native clients read a compact local payload and do
not parse traces or read provider credentials directly. Token Meter checks for
updates every 10 minutes and installs safe `main` updates automatically by
default, so normal updates do not require opening the dashboard. If automatic
updates every 10 minutes. Automatic installation of `main` updates is off by
default and only runs from the official GitHub origin. If automatic
installation is off, the native menu shows **New update available** instead.

<p align="center">
Expand Down
34 changes: 10 additions & 24 deletions page.html
Original file line number Diff line number Diff line change
Expand Up @@ -1179,10 +1179,10 @@ <h2 id=onboarding-title>Getting started</h2>
<div class="settingsStatus deliveryClearStatus" id=delivery-clear-status>Stored locally by Token Meter.</div>
</section>
<section class="card pad softwareUpdates" id=update-settings>
<div class=softwareUpdateHead><div><h2 class=fieldtip tabindex=0 aria-description="Checks fetch revision metadata only. Safe updates from the tracked main branch can install automatically without opening the dashboard." data-tip="Checks fetch revision metadata only. Safe updates from the tracked main branch can install automatically without opening the dashboard.">Software updates</h2></div><span class=agentAccessBadge id=update-settings-badge>Waiting</span></div>
<div class=softwareUpdateHead><div><h2 class=fieldtip tabindex=0 aria-description="Checks fetch revision metadata only. Automatic installs stay off until you enable them, and only from the official GitHub origin." data-tip="Checks fetch revision metadata only. Automatic installs stay off until you enable them, and only from the official GitHub origin.">Software updates</h2></div><span class=agentAccessBadge id=update-settings-badge>Waiting</span></div>
<div class=softwareUpdateToggles>
<label class="softwareUpdateToggle fieldtip" tabindex=0 aria-description="Check for updates every 10 minutes. Enabled by default. Token Meter checks once at startup, then every 10 minutes while it is running." data-tip="Check for updates every 10 minutes. Enabled by default. Token Meter checks once at startup, then every 10 minutes while it is running."><input id=update-enabled type=checkbox checked><span><b>Check for updates every 10 minutes</b><small>Off also disables auto-install.</small></span></label>
<label class="softwareUpdateToggle fieldtip" tabindex=0 aria-description="Automatically install safe updates from the tracked main branch. Enabled by default." data-tip="Automatically install safe updates from the tracked main branch. Enabled by default."><input id=update-auto-install type=checkbox checked><span><b>Automatically install available updates</b><small>Checks continue when off.</small></span></label>
<label class="softwareUpdateToggle fieldtip" tabindex=0 aria-description="Automatically install safe updates from the official main branch. Off by default." data-tip="Automatically install safe updates from the official main branch. Off by default."><input id=update-auto-install type=checkbox><span><b>Automatically install available updates</b><small>Checks continue when off.</small></span></label>
</div>
<div class=softwareUpdateActions><button class=tbtn id=update-check type=button>Check now</button><span class=settingsStatus id=update-settings-status>Waiting for the first update check.</span><span class=softwareUpdateMeta id=update-settings-meta></span></div>
</section>
Expand Down Expand Up @@ -3211,10 +3211,9 @@ <h3 id=session-model-identity-title>Assign model to this session</h3>
const groups=[];
let cur=null;
(series||[]).forEach((s,idx)=>{
const msg=String(s.user_message||s.user_input||'').trim();
const exec=s.i??idx+1;
if(msg||!cur){
cur={i:groups.length+1,message:msg||'No user message captured',hasMessage:!!msg,start:exec,end:exec,steps:0,cost:0,input:0,output:0,tools:0,peakContext:null};
if(!cur){
cur={i:groups.length+1,message:'',hasMessage:false,start:exec,end:exec,steps:0,cost:0,input:0,output:0,tools:0,peakContext:null};
groups.push(cur);
}
const p=chartParts(s);
Expand Down Expand Up @@ -3268,7 +3267,7 @@ <h3 id=session-model-identity-title>Assign model to this session</h3>
tip.style.display='block'; tip.style.left=left+'px'; tip.style.top=top+'px';
const row=(cls,name,val)=>`<div class="r ${cls}"><span>${name}</span><b>${val}</b></div>`;
const execRange=g.start===g.end?`#${g.start}`:`#${g.start}-${g.end}`;
const messageBlock=g.hasMessage?`<div class=prompt><span>user message</span><b>${esc(g.message)}</b></div>`:'';
const messageBlock='';
const est=CURRENT?.provider==='cursor'?' est':'';
tip.innerHTML=`<div class=h>request #${g.i}</div>${row('tools','steps',f(g.steps))}${row('in','executions',execRange)}${row('cost','cost',metricAvailable(CURRENT,'cost')?money(g.cost)+est:'--')}${row('in',est?'input context':'input total',metricAvailable(CURRENT,'input_tokens')?f(g.input)+est:'--')}${row('out',est?'visible output':'output',metricAvailable(CURRENT,'output_tokens')?f(g.output)+est:'--')}${row('tools','tool calls',f(g.tools))}${row('context','peak context',g.peakContext===null?'--':pct(g.peakContext))}${messageBlock}`;
}
Expand Down Expand Up @@ -3303,7 +3302,7 @@ <h3 id=session-model-identity-title>Assign model to this session</h3>
const local=point.matrixTransform(svg.getScreenCTM().inverse()),index=Math.max(0,Math.min(n-1,Math.round((local.x-ML)/PW*(n-1))));
const row=samples[index],xx=x(index),yy=y(row.duration_s),group=groups[index];
hoverGroup.setAttribute('opacity','1');cross.setAttribute('x1',xx);cross.setAttribute('x2',xx);dot.setAttribute('cx',xx);dot.setAttribute('cy',yy);
if(tip){const rect=svg.parentElement.getBoundingClientRect(),message=group?.hasMessage?`<div class=prompt><span>user message</span><b>${esc(group.message)}</b></div>`:'',cursor=CURRENT?.provider==='cursor';tip.innerHTML=`<div class=h>request #${index+1}</div><div class="r context"><span>wait time</span><b>${waitFmt(row.duration_s)}</b></div><div class=r><span>model</span><b>${esc(row.model||'unknown')}</b></div><div class="r tools"><span>tool calls</span><b>${f(row.tool_calls)}</b></div><div class="r out"><span>${cursor?'visible output est':'output'}</span><b>${metricAvailable(CURRENT,'output_tokens')?f(row.output_tokens)+' tok'+(cursor?' est':''):'--'}</b></div><div class=r><span>timing</span><b>${esc(row.timing_basis||'observed')}</b></div>${message}`;tip.style.display='block';tip.style.left=Math.min(rect.width-tip.offsetWidth-8,Math.max(8,clientX-rect.left+12))+'px';tip.style.top=Math.max(8,clientY-rect.top-tip.offsetHeight-10)+'px';}
if(tip){const rect=svg.parentElement.getBoundingClientRect(),cursor=CURRENT?.provider==='cursor';tip.innerHTML=`<div class=h>request #${index+1}</div><div class="r context"><span>wait time</span><b>${waitFmt(row.duration_s)}</b></div><div class=r><span>model</span><b>${esc(row.model||'unknown')}</b></div><div class="r tools"><span>tool calls</span><b>${f(row.tool_calls)}</b></div><div class="r out"><span>${cursor?'visible output est':'output'}</span><b>${metricAvailable(CURRENT,'output_tokens')?f(row.output_tokens)+' tok'+(cursor?' est':''):'--'}</b></div><div class=r><span>timing</span><b>${esc(row.timing_basis||'observed')}</b></div>`;tip.style.display='block';tip.style.left=Math.min(rect.width-tip.offsetWidth-8,Math.max(8,clientX-rect.left+12))+'px';tip.style.top=Math.max(8,clientY-rect.top-tip.offsetHeight-10)+'px';}
}
hit.addEventListener('mousemove',event=>hoverAt(event.clientX,event.clientY));
hit.addEventListener('mouseleave',()=>{hoverGroup.setAttribute('opacity','0');if(tip)tip.style.display='none';});
Expand Down Expand Up @@ -3402,13 +3401,7 @@ <h3 id=session-model-identity-title>Assign model to this session</h3>
if(p.tools>=toolCut) marks+=`<line x1="${(xx-3).toFixed(1)}" y1="${(y0-7).toFixed(1)}" x2="${(xx-3).toFixed(1)}" y2="${y0.toFixed(1)}" stroke="${CHART.tool}" stroke-width="2.2" stroke-linecap="round" opacity=".75"/>`;
if(p.reason>=reasonCut) marks+=`<line x1="${(xx+3).toFixed(1)}" y1="${(y0-7).toFixed(1)}" x2="${(xx+3).toFixed(1)}" y2="${y0.toFixed(1)}" stroke="${CHART.reason}" stroke-width="2.2" stroke-linecap="round" opacity=".75"/>`;
if(s.side) marks+=`<circle cx="${xx.toFixed(1)}" cy="${(y0-3).toFixed(1)}" r="3" fill="${CHART.side}" opacity=".85"/>`;
if(s.user_message||s.user_input){
const u=primaryFor(p);
if(u.value!==null&&u.value!==undefined&&!Number.isNaN(u.value)){
const yy=y(u.value);
marks+=`<path d="M ${xx.toFixed(1)} ${(yy-5).toFixed(1)} L ${(xx+5).toFixed(1)} ${yy.toFixed(1)} L ${xx.toFixed(1)} ${(yy+5).toFixed(1)} L ${(xx-5).toFixed(1)} ${yy.toFixed(1)} Z" fill="${CHART.tip}" stroke="${u.color}" stroke-width="2" opacity=".96"/>`;
}
}

});
const yTitle=mode==='cost'?(cumulativeCost?'cumulative usd':'usd'):(mode==='context'?'context':(mode==='tools'?'tool calls':(cumulativeTokens?'cumulative tokens':'tokens')));
const titles=`<text x=${(ML+PW/2).toFixed(1)} y=${VH-1} text-anchor=middle style="${T}">execution</text><text x=14 y=${(MT+PH/2).toFixed(1)} text-anchor=middle transform="rotate(-90 14 ${(MT+PH/2).toFixed(1)})" style="${T}">${yTitle}</text>`;
Expand Down Expand Up @@ -3441,10 +3434,8 @@ <h3 id=session-model-identity-title>Assign model to this session</h3>
tip.style.left=left+'px';
tip.style.top=top+'px';
const row=(cls,name,val)=>`<div class="r ${cls}"><span>${name}</span><b>${val}</b></div>`;
const userMessage=s.user_message||s.user_input||'';
const markers=[userMessage?'user message':null,p.tools?`${f(p.tools)} tool calls`:null,p.reason?`${f(p.reason)} reasoning`:null,s.side?'coordination':null].filter(Boolean).join(' / ');
const prompt=userMessage?esc(userMessage):'--';
const promptBlock=userMessage?`<div class=prompt><span>user message</span><b>${prompt}</b></div>`:'';
const markers=[p.tools?`${f(p.tools)} tool calls`:null,p.reason?`${f(p.reason)} reasoning`:null,s.side?'coordination':null].filter(Boolean).join(' / ');
const promptBlock='';
const cursor=CURRENT?.provider==='cursor',est=cursor?' est':'';
const costLabel=cumulativeCost?'cumulative cost':'cost';
const common=[
Expand Down Expand Up @@ -3548,12 +3539,7 @@ <h3 id=session-model-identity-title>Assign model to this session</h3>
}

function sessionStartMessage(s){
for(const row of (s?.series||[])){
const message=String(row?.user_message||row?.user_input||'').trim();
if(message)return message;
}
const event=(s?.trace||[]).find(row=>row?.kind==='user'||row?.label==='User message');
return String(event?.detail||'').trim();
return '';
}

function sessionDisplayName(s){
Expand Down
11 changes: 11 additions & 0 deletions scripts/install-systemd-user
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,17 @@ TRAY_UNIT="$SYSTEMD_USER_DIR/token-meter-tray.service"
PYTHON_BIN="$(command -v python3 || true)"
UNIT_ROOT="${ROOT// /\x20}"

case "$ROOT" in
*$'\n'*|*$'\r'*|*$'"'*|*$'`'*|*$'\\'*|*$'$'*)
echo "Token Meter install path contains unsupported characters." >&2
exit 1
;;
esac
if [[ "$ROOT" == *[[:cntrl:]]* || "$PYTHON_BIN" == *[[:cntrl:]]* || "$PYTHON_BIN" == *$'"'* ]]; then
echo "Token Meter install path contains unsupported characters." >&2
exit 1
fi

case "$MODE" in
all|server-only|menubar-only) ;;
*)
Expand Down
19 changes: 10 additions & 9 deletions specs/USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -226,17 +226,18 @@ model provider under its own terms.

## Software Updates

**Check for updates every 10 minutes** and **Automatically install available
updates** are both enabled by default. They are separate controls: turning off
automatic installation keeps checks running, while turning off checks also
turns off automatic installation. The interval is fixed at 10 minutes while
the server is active. Checks fetch revision metadata without modifying the
checkout.
**Check for updates every 10 minutes** is enabled by default.
**Automatically install available updates** is off by default. They are
separate controls: turning off automatic installation keeps checks running,
while turning off checks also turns off automatic installation. The interval
is fixed at 10 minutes while the server is active. Checks fetch revision
metadata without modifying the checkout.

Automatic installation is limited to a managed checkout that is on `main`,
tracks a remote `main`, is clean and non-diverged, and is behind upstream. A
safe update fast-forwards the checkout, reruns the installer, and returns after
the local server restarts. Other branches, dirty checkouts, and diverged
tracks the official `https://github.com/splunk/token-meter.git` origin, is
clean and non-diverged, and is behind upstream. A safe update fast-forwards
the checkout, reruns the installer, and returns after the local server
restarts. Other branches, dirty checkouts, untrusted origins, and diverged
history remain untouched and report that the update needs attention.

Normal automatic updates do not require the dashboard. When automatic
Expand Down
9 changes: 9 additions & 0 deletions tests/contracts/test_quota_privacy.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,15 @@ def test_http_response_is_bounded_before_json_parsing(self):
opener=lambda request, timeout: _Response(oversized),
)

def test_redirects_fail_closed_without_following(self):
from token_meter.quotas.common import _NoRedirectHandler

handler = _NoRedirectHandler()
with self.assertRaises(QuotaUnavailable) as raised:
handler.redirect_request(None, None, 302, "Found", {}, "https://evil.example/x")
self.assertEqual(str(raised.exception), "Provider quota request redirected.")
self.assertNotIn("evil.example", str(raised.exception))

def test_unavailable_quota_is_not_reported_as_measured_zero(self):
snapshot = quota_provider(
"provider",
Expand Down
2 changes: 2 additions & 0 deletions tests/test_codex_model_identity.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ def test_local_action_route_accepts_only_the_opaque_session_key(self):
handler = object.__new__(meter.H)
handler.path = "/settings/session-model-identity"
handler.headers = {
"Host": "127.0.0.1:8722",
"Content-Type": "application/json",
"Content-Length": "115",
"X-Token-Meter-Action": meter._ACTION_TOKEN,
Expand Down Expand Up @@ -369,6 +370,7 @@ def test_local_action_route_forwards_the_hermes_provider(self):
"provider": "hermes",
}).encode("utf-8")
handler.headers = {
"Host": "127.0.0.1:8722",
"Content-Type": "application/json",
"Content-Length": str(len(body)),
"X-Token-Meter-Action": meter._ACTION_TOKEN,
Expand Down
13 changes: 13 additions & 0 deletions tests/test_git_delivery.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,19 @@ def test_subprocess_runner_supplies_a_system_path_for_launch_agents(self):
)

self.assertEqual(run.call_args.kwargs["env"]["PATH"], os.defpath)
self.assertEqual(run.call_args.kwargs["env"]["GIT_CONFIG_NOSYSTEM"], "1")
self.assertNotIn("GIT_CONFIG_GLOBAL", run.call_args.kwargs["env"])

def test_git_argv_disables_aliases_and_hooks(self):
argv = git_delivery.git_argv("/repo", ("rev-parse", "--show-toplevel"))
self.assertEqual(argv[0], "git")
self.assertIn("core.hooksPath=/dev/null", argv)
self.assertIn("alias.rev-parse=", argv)
self.assertIn("/repo", argv)
with self.assertRaises(ValueError):
git_delivery.git_argv("/repo", ("fetch", "origin"))
with self.assertRaises(ValueError):
git_delivery.git_argv("/repo", ("-C", "/tmp"))

def test_scan_limits_generator_candidates_without_losing_limit_coverage(self):
with tempfile.TemporaryDirectory() as tmp:
Expand Down
Loading