@platys/relay 0.8.0-alpha.15 → 0.8.0-alpha.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -29,6 +29,11 @@ npm install --global @platys/relay
29
29
  relay setup
30
30
  ```
31
31
 
32
+ Version **0.8.0-alpha.16** adds local token usage with `relay usage`, a separate
33
+ browser window with `relay usage --web`, and a draggable Windows speech bubble
34
+ with `relay usage --overlay`. Usage reads retained local provider counters and
35
+ does not interrupt your agent or upload history.
36
+
32
37
  Version **0.8.0-alpha.15** adds saved peer-session selection, one-line task recaps,
33
38
  explicit conversation handover, older-session filtering, and optional shared-work
34
39
  coordination. Ordinary questions and replies remain the default. Existing clients
@@ -66,6 +71,35 @@ their agent sessions open. No shared repository or workspace selection is needed
66
71
 
67
72
  You can also ask questions from the **shared room** link shown after setup.
68
73
 
74
+ ## Token usage without interrupting your agent
75
+
76
+ On Windows, run `relay usage --overlay` for a small, always-on-top speech bubble.
77
+ Drag its heading above your Codex pet, click **Open full usage** for details, or
78
+ use **×** to hide it. It refreshes automatically once a minute after each completed
79
+ read. Use `--session NATIVE_ID` to keep it on one conversation; otherwise it follows
80
+ the most recently active session with counters. Today and lifetime combine the
81
+ selected providers' retained local history. The bubble stays where you place it;
82
+ it does not attach to or move with the pet. Opening it and refreshing counters
83
+ do not activate the window or send prompts to the agent. Windows PowerShell's
84
+ built-in WPF support is required; macOS/Linux can use the browser window below.
85
+
86
+ Run `relay usage --web` in another terminal to open a separate local usage window.
87
+ The command returns immediately; your coding conversation keeps working. Pick a
88
+ conversation to see its tokens, or check today and lifetime totals for Codex and
89
+ Claude Code. Click **Refresh usage** for updated counters. The window is served
90
+ locally for 30 minutes; run the command again to reopen it.
91
+
92
+ Use `relay usage` for a terminal summary, `relay usage --session NATIVE_ID` for a
93
+ specific conversation, or `relay usage --json` for all counters. Without a native
94
+ session ID from the environment, Relay selects the most recently active session
95
+ with recorded usage and labels it explicitly. No Relay connection is required.
96
+
97
+ Usage comes from provider counters in retained local history, including work
98
+ outside Relay. Nothing is uploaded. Lifetime covers this device's available logs;
99
+ deleted history, other devices and inherited fork counters can affect totals.
100
+ These totals do not represent subscription allowances or billed costs. Relay does
101
+ not install a native Codex slash-command overlay.
102
+
69
103
  ## How delivery works
70
104
 
71
105
  | Recipient | What happens |
@@ -3,6 +3,19 @@
3
3
  Relay connects your existing Claude Code and Codex conversations with your
4
4
  friends' agents. Each person installs it once. You do not need the same workspace.
5
5
 
6
+ ## Check token usage while your agent works
7
+
8
+ With Relay 0.8.0-alpha.16 or newer, run `relay usage --overlay` on Windows for a
9
+ draggable speech bubble. Place it above your pet; it refreshes automatically.
10
+ Click **Open full usage** for details or **×** to hide it. The bubble does not
11
+ move with the pet. On any supported platform, `relay usage --web` opens a separate
12
+ browser window. Neither view sends a prompt to your agent.
13
+
14
+ Update a global install with `npm install --global @platys/relay@latest`.
15
+ Without a global install, run `npx --yes @platys/relay@latest usage --overlay`
16
+ (Windows) or replace `--overlay` with `--web`. Usage reads retained local provider
17
+ counters; lifetime covers this device's available history, not billing limits.
18
+
6
19
  ## Before you start
7
20
 
8
21
  - Install and sign in to Claude Code or Codex.
@@ -1 +1 @@
1
- export const VERSION = '0.8.0-alpha.15';
1
+ export const VERSION = '0.8.0-alpha.16';
package/out/node/cli.js CHANGED
@@ -25,10 +25,13 @@ import { setupNetwork } from './network-setup.js';
25
25
  import { openSetupWeb } from './setup-web.js';
26
26
  import { setupRemote, useRemoteSetup } from './remote-setup.js';
27
27
  import { VERSION } from '../core/version.js';
28
+ import { readUsage, formatUsage } from './usage.js';
29
+ import { startUsageWeb, openUsageWindow } from './usage-web.js';
30
+ import { runUsageOverlay } from './usage-overlay.js';
28
31
  const cli = fileURLToPath(import.meta.url);
29
32
  const parsed = parseArgs({ allowPositionals: true, options: Object.fromEntries([
30
33
  'repo', 'server', 'invite', 'name', 'provider', 'repository', 'session', 'from', 'to', 'message', 'query', 'dir', 'port', 'create-key', 'args', 'id', 'backup', 'days', 'member', 'role'
31
- ].map(name => [name, { type: 'string' }]).concat([['version', { type: 'boolean' }], ['approve', { type: 'boolean' }], ['json', { type: 'boolean' }], ['no-install', { type: 'boolean' }], ['no-start', { type: 'boolean' }], ['help', { type: 'boolean' }], ['relay-hook-v1', { type: 'boolean' }], ['global', { type: 'boolean' }], ['all-workspaces', { type: 'boolean' }], ['relay-network-hook-v1', { type: 'boolean' }], ['web', { type: 'boolean' }], ['no-open', { type: 'boolean' }], ['terminal', { type: 'boolean' }], ['remote', { type: 'boolean' }], ['legacy-network', { type: 'boolean' }]])) });
34
+ ].map(name => [name, { type: 'string' }]).concat([['version', { type: 'boolean' }], ['approve', { type: 'boolean' }], ['json', { type: 'boolean' }], ['no-install', { type: 'boolean' }], ['no-start', { type: 'boolean' }], ['help', { type: 'boolean' }], ['relay-hook-v1', { type: 'boolean' }], ['global', { type: 'boolean' }], ['all-workspaces', { type: 'boolean' }], ['relay-network-hook-v1', { type: 'boolean' }], ['web', { type: 'boolean' }], ['overlay', { type: 'boolean' }], ['no-open', { type: 'boolean' }], ['terminal', { type: 'boolean' }], ['remote', { type: 'boolean' }], ['legacy-network', { type: 'boolean' }]])) });
32
35
  const values = parsed.values, positionals = parsed.positionals;
33
36
  const option = (name) => typeof values[name] === 'string' ? values[name] : undefined;
34
37
  const help = `Relay Next — shared context for your existing coding sessions
@@ -57,6 +60,10 @@ const help = `Relay Next — shared context for your existing coding sessions
57
60
  doctor Check connector, MCP startup, session registration and legacy conflicts
58
61
  project Read accepted revision, tasks and current decisions
59
62
  metrics Read payload sizes and operation counts
63
+ usage Show local session, today and lifetime model tokens
64
+ usage --web Open a separate usage window; keep your agent working
65
+ usage --overlay Show a draggable speech bubble above your pet (Windows)
66
+ usage --session ID Inspect a native conversation; --provider codex|claude --json
60
67
  budget Read cached shared monthly budget and pause alerts (local only)
61
68
  budget-refresh Explicitly check whether the owner approved resuming this service
62
69
  task --args JSON Reserve/renew/handoff/release scopes
@@ -209,6 +216,34 @@ async function main() {
209
216
  console.log(help);
210
217
  return;
211
218
  }
219
+ if (cmd === 'usage') {
220
+ const options = { provider: option('provider'), session: option('session') ?? process.env.CODEX_THREAD_ID ?? process.env.CLAUDE_CODE_SESSION_ID };
221
+ if (values.overlay) {
222
+ if (values.web)
223
+ throw Error('Choose --overlay or --web');
224
+ if (process.platform !== 'win32')
225
+ throw Error('The usage overlay currently requires Windows. Use relay usage --web.');
226
+ if (values['no-start'] && process.send) {
227
+ await runUsageOverlay(options, url => process.send?.(url));
228
+ return;
229
+ }
230
+ await openUsageWindow(cli, { ...options, overlay: true });
231
+ return;
232
+ }
233
+ if (values.web) {
234
+ if (values['no-start'] && process.send) {
235
+ const web = await startUsageWeb(options);
236
+ process.send(web.url);
237
+ setTimeout(() => void web.close(), 30 * 60_000).unref();
238
+ return;
239
+ }
240
+ await openUsageWindow(cli, { ...options, noOpen: !!values['no-open'] });
241
+ return;
242
+ }
243
+ const report = await readUsage(options);
244
+ console.log(values.json ? JSON.stringify(report, null, 2) : formatUsage(report));
245
+ return;
246
+ }
212
247
  if (cmd === 'budget') {
213
248
  console.log(JSON.stringify(clientBudget.status(), null, 2));
214
249
  return;
@@ -0,0 +1,130 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { startUsageWeb } from './usage-web.js';
3
+ // WPF ships with Windows PowerShell. Keep UI work in its STA process and usage
4
+ // collection in Node; an asynchronous HTTP request never blocks dragging or hiding.
5
+ export function usageOverlayScript(url) {
6
+ const encoded = Buffer.from(url, 'utf8').toString('base64');
7
+ return String.raw `
8
+ $ErrorActionPreference = 'Stop'
9
+ Add-Type -AssemblyName PresentationFramework, PresentationCore, WindowsBase, System.Net.Http
10
+ $usageUrl = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encoded}'))
11
+ $address = [Uri]$usageUrl
12
+ $handler = New-Object System.Net.Http.HttpClientHandler
13
+ $handler.UseProxy = $false
14
+ $client = New-Object System.Net.Http.HttpClient($handler)
15
+ $client.Timeout = [TimeSpan]::FromMinutes(2)
16
+ $client.DefaultRequestHeaders.Authorization = New-Object System.Net.Http.Headers.AuthenticationHeaderValue('Bearer', $address.Fragment.Substring(1))
17
+ $endpoint = $address.GetLeftPart([UriPartial]::Authority) + '/api/usage'
18
+ [xml]$xaml = @'
19
+ <Window xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
20
+ Title="Relay token usage" Width="340" SizeToContent="Height" WindowStyle="None" ResizeMode="NoResize" AllowsTransparency="True" Background="Transparent" Topmost="True" ShowActivated="False" FontFamily="Segoe UI" UseLayoutRounding="True">
21
+ <Grid Margin="10">
22
+ <Grid.RowDefinitions><RowDefinition Height="Auto"/><RowDefinition Height="18"/></Grid.RowDefinitions>
23
+ <Border Background="#FFF9ED" BorderBrush="#D9CDB5" BorderThickness="1" CornerRadius="18" Padding="20,14,20,16">
24
+ <Border.Effect><DropShadowEffect BlurRadius="12" ShadowDepth="2" Opacity="0.22"/></Border.Effect>
25
+ <StackPanel>
26
+ <DockPanel LastChildFill="True" Margin="0,0,0,10">
27
+ <Button x:Name="Hide" DockPanel.Dock="Right" Content="&#x00D7;" Width="26" Height="26" FontSize="19" Background="Transparent" BorderThickness="0" Foreground="#615747" AutomationProperties.Name="Hide Relay usage" ToolTip="Hide. Reopen with relay usage --overlay."/>
28
+ <TextBlock x:Name="DragHandle" Text="RELAY USAGE" Foreground="#615747" FontSize="12" FontWeight="SemiBold" Padding="0,5,0,5" Cursor="SizeAll" ToolTip="Drag this bubble above your pet"/>
29
+ </DockPanel>
30
+ <TextBlock x:Name="SessionLabel" Text="Reading local counters..." FontSize="12" Foreground="#615747" TextWrapping="Wrap"/>
31
+ <TextBlock x:Name="SessionTokens" Text="..." FontSize="32" FontWeight="SemiBold" Foreground="#252A2D" Margin="0,2,0,10"/>
32
+ <Grid Margin="0,0,0,10"><Grid.ColumnDefinitions><ColumnDefinition/><ColumnDefinition/></Grid.ColumnDefinitions>
33
+ <StackPanel><TextBlock Text="TODAY" FontSize="11" Foreground="#615747"/><TextBlock x:Name="Today" Text="..." FontSize="20" Foreground="#252A2D"/></StackPanel>
34
+ <StackPanel Grid.Column="1"><TextBlock Text="LIFETIME" FontSize="11" Foreground="#615747"/><TextBlock x:Name="Lifetime" Text="..." FontSize="20" Foreground="#252A2D"/></StackPanel>
35
+ </Grid>
36
+ <Button x:Name="Details" Content="Open full usage &#x2197;" HorizontalAlignment="Left" Padding="0,5" Background="Transparent" BorderThickness="0" Foreground="#225D70" FontWeight="SemiBold" AutomationProperties.Name="Open full Relay token usage in your browser"/>
37
+ <TextBlock x:Name="Status" Text="Local history only. Your agent keeps working." TextWrapping="Wrap" FontSize="11" Foreground="#615747" Margin="0,6,0,0"/>
38
+ </StackPanel>
39
+ </Border>
40
+ <Path Grid.Row="1" HorizontalAlignment="Right" Margin="0,-1,40,0" Data="M 0,0 L 22,17 L 24,0" Fill="#FFF9ED" Stroke="#D9CDB5" StrokeThickness="1"/>
41
+ </Grid>
42
+ </Window>
43
+ '@
44
+ $reader = New-Object System.Xml.XmlNodeReader($xaml)
45
+ $window = [Windows.Markup.XamlReader]::Load($reader)
46
+ $sessionLabel = $window.FindName('SessionLabel')
47
+ $sessionTokens = $window.FindName('SessionTokens')
48
+ $today = $window.FindName('Today')
49
+ $lifetime = $window.FindName('Lifetime')
50
+ $status = $window.FindName('Status')
51
+ $window.Left = [Math]::Max(0, [Windows.SystemParameters]::WorkArea.Right - $window.Width - 30)
52
+ $window.Top = [Math]::Max(0, [Windows.SystemParameters]::WorkArea.Bottom - 420)
53
+ $window.FindName('DragHandle').Add_MouseLeftButtonDown({ if ($_.ButtonState -eq 'Pressed') { $window.DragMove() } })
54
+ $window.FindName('Hide').Add_Click({ $window.Close() })
55
+ $window.FindName('Details').Add_Click({
56
+ try { [Diagnostics.Process]::Start($usageUrl) | Out-Null } catch { $status.Text = 'Could not open browser. Run relay usage --web.' }
57
+ })
58
+ function ShortTokens([double]$value) {
59
+ if ($value -ge 1e9) { return ($value / 1e9).ToString('0.##') + 'B' }
60
+ if ($value -ge 1e6) { return ($value / 1e6).ToString('0.##') + 'M' }
61
+ if ($value -ge 1e3) { return ($value / 1e3).ToString('0.#') + 'K' }
62
+ return $value.ToString('N0')
63
+ }
64
+ $script:request = $null
65
+ $script:nextRefresh = [DateTime]::MinValue
66
+ $timer = New-Object Windows.Threading.DispatcherTimer
67
+ $timer.Interval = [TimeSpan]::FromMilliseconds(250)
68
+ $timer.Add_Tick({
69
+ try { [Diagnostics.Process]::GetProcessById(${process.pid}) | Out-Null } catch { $window.Close(); return }
70
+ if ($null -eq $script:request) {
71
+ if ([DateTime]::UtcNow -lt $script:nextRefresh) { return }
72
+ $status.Text = 'Refreshing local counters...'
73
+ $script:request = $client.GetStringAsync($endpoint)
74
+ return
75
+ }
76
+ if (-not $script:request.IsCompleted) { return }
77
+ try {
78
+ $report = $script:request.GetAwaiter().GetResult() | ConvertFrom-Json
79
+ if ($null -ne $report.session) {
80
+ $label = if ($report.selection -eq 'requested session') { 'This session' } else { 'Most recently active session' }
81
+ $sessionLabel.Text = $label + ' / ' + $report.session.provider
82
+ $sessionLabel.ToolTip = $report.session.id
83
+ $sessionTokens.Text = (ShortTokens $report.session.tokens.total) + ' tokens'
84
+ $sessionTokens.ToolTip = $report.session.tokens.total.ToString('N0') + ' recorded tokens'
85
+ } else { $sessionLabel.Text = 'No session counters available'; $sessionTokens.Text = '--' }
86
+ [double]$dayTotal = 0; [double]$allTotal = 0; [int]$sessionCount = 0
87
+ foreach ($provider in $report.providers.PSObject.Properties.Value) {
88
+ $dayTotal += $provider.today.total; $allTotal += $provider.lifetime.total; $sessionCount += $provider.sessions
89
+ }
90
+ if ($sessionCount -gt 0) {
91
+ $today.Text = ShortTokens $dayTotal; $lifetime.Text = ShortTokens $allTotal
92
+ $today.ToolTip = $dayTotal.ToString('N0') + ' tokens / local day'
93
+ $lifetime.ToolTip = $allTotal.ToString('N0') + ' tokens / retained local history'
94
+ } else { $today.Text = '--'; $lifetime.Text = '--' }
95
+ $status.Text = 'Local history / updated ' + (Get-Date).ToString('HH:mm:ss')
96
+ if ($report.coverage.unreadable -gt 0 -or $report.coverage.malformedLines -gt 0 -or $report.coverage.sessionsWithoutCounters -gt 0) { $status.Text += ' / gaps in history' }
97
+ $status.ToolTip = $report.limitations
98
+ } catch { $status.Text = 'Refresh failed. Retrying in one minute.' }
99
+ $script:request = $null
100
+ $script:nextRefresh = [DateTime]::UtcNow.AddMinutes(1)
101
+ })
102
+ $window.Add_ContentRendered({ [Console]::WriteLine('RELAY_OVERLAY_READY'); [Console]::Out.Flush() })
103
+ $window.Add_Closed({ $timer.Stop(); $client.CancelPendingRequests(); $client.Dispose(); $handler.Dispose() })
104
+ $timer.Start()
105
+ $window.ShowDialog() | Out-Null
106
+ `;
107
+ }
108
+ export async function runUsageOverlay(options, ready) {
109
+ if (process.platform !== 'win32')
110
+ throw Error('The usage overlay currently requires Windows. Use relay usage --web on this platform.');
111
+ const web = await startUsageWeb(options);
112
+ const script = Buffer.from(usageOverlayScript(web.url), 'utf16le').toString('base64');
113
+ const child = spawn('powershell.exe', ['-NoProfile', '-NonInteractive', '-STA', '-WindowStyle', 'Hidden', '-EncodedCommand', script], { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] });
114
+ let shown = false, output = '';
115
+ try {
116
+ await new Promise((done, reject) => {
117
+ child.stdout.on('data', chunk => { output += chunk.toString(); if (!shown && output.includes('RELAY_OVERLAY_READY')) {
118
+ shown = true;
119
+ ready(web.url);
120
+ } });
121
+ child.stderr.on('data', () => { }); // Do not expose private local URLs or PowerShell source on errors.
122
+ child.once('error', reject);
123
+ child.once('exit', code => shown && code === 0 ? done() : reject(Error('Windows could not display the Relay usage overlay.')));
124
+ });
125
+ }
126
+ finally {
127
+ child.kill();
128
+ await web.close();
129
+ }
130
+ }
@@ -0,0 +1,79 @@
1
+ import { createServer } from 'node:http';
2
+ import { randomBytes } from 'node:crypto';
3
+ import { fork, spawn } from 'node:child_process';
4
+ import { readUsage } from './usage.js';
5
+ export function usagePage(nonce) {
6
+ return `<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Relay usage</title>
7
+ <style nonce="${nonce}">body{font:16px system-ui;background:#101820;color:#edf3f5;max-width:850px;margin:40px auto;padding:24px}h1{font-size:30px}button,select{font:inherit;padding:10px;margin:8px 8px 8px 0;max-width:100%}pre{white-space:pre-wrap;line-height:1.7}section{background:#1c2933;padding:20px;border-radius:12px;margin:16px 0}.muted{color:#b7c6ce}label{display:block}a{color:#a9d9ff}</style>
8
+ <h1>Relay usage</h1><p class="muted">Check usage while your agent keeps working. All data stays on this device.</p><button id="refresh">Refresh usage</button><span id="updated" role="status"></span>
9
+ <section><label for="sessions">Conversation</label><select id="sessions"></select><pre id="session"></pre></section><section><h2>Today &amp; lifetime</h2><pre id="totals"></pre></section><p id="coverage" class="muted"></p><p id="limits" class="muted"></p><p id="error" role="alert"></p>
10
+ <script nonce="${nonce}">
11
+ const token=location.hash.slice(1);history.replaceState(null,'',location.pathname);let data;
12
+ const el=id=>document.getElementById(id),n=x=>x.toLocaleString(),describe=t=>n(t.total)+' total · '+n(t.input)+' input · '+n(t.output)+' output\\n'+n(t.cachedInput)+' cached input · '+n(t.cacheWrite)+' cache writes (included in input)';
13
+ function selected(){const s=data.sessions.find(s=>s.provider+':'+s.id===el('sessions').value);el('session').textContent=s?describe(s.tokens):'No recorded token counters available.';}
14
+ async function refresh(){el('refresh').disabled=true;el('error').textContent='';try{const r=await fetch('/api/usage',{headers:{Authorization:'Bearer '+token}});if(!r.ok)throw Error('Usage unavailable. Reopen with relay usage --web.');data=await r.json();const previous=el('sessions').value;el('sessions').replaceChildren();for(const s of data.sessions){const o=document.createElement('option');o.value=s.provider+':'+s.id;o.textContent=s.provider+' · '+s.id;el('sessions').append(o);}if([...el('sessions').options].some(o=>o.value===previous))el('sessions').value=previous;else if(data.session)el('sessions').value=data.session.provider+':'+data.session.id;selected();el('totals').textContent=Object.entries(data.providers).filter(([k,v])=>v.sessions).map(([k,v])=>k+' · '+v.sessions+' sessions\\nToday: '+n(v.today.total)+' tokens (local day)\\nLifetime: '+describe(v.lifetime)).join('\\n\\n')||'No usage history found.';el('updated').textContent='Updated '+new Date(data.asOf).toLocaleTimeString();el('coverage').textContent=data.scope+' '+data.coverage.files+' files; '+data.coverage.unreadable+' unreadable; '+data.coverage.malformedLines+' malformed/incomplete lines; '+data.coverage.sessionsWithoutCounters+' sessions without counters.';el('limits').textContent=data.limitations;}catch(e){el('error').textContent=e.message;}finally{el('refresh').disabled=false;}}
15
+ el('sessions').onchange=selected;el('refresh').onclick=refresh;refresh();
16
+ </script></html>`;
17
+ }
18
+ export async function startUsageWeb(options = {}) {
19
+ const token = randomBytes(32).toString('base64url');
20
+ let origin = '', pending;
21
+ const server = createServer(async (req, res) => {
22
+ res.setHeader('Cache-Control', 'no-store');
23
+ res.setHeader('X-Content-Type-Options', 'nosniff');
24
+ res.setHeader('Referrer-Policy', 'no-referrer');
25
+ const send = (status, value) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(value)); };
26
+ if (req.headers.host !== origin.slice(7) || req.headers.origin && req.headers.origin !== origin) {
27
+ send(403, { error: 'Forbidden' });
28
+ return;
29
+ }
30
+ if (req.method === 'GET' && req.url === '/') {
31
+ const nonce = randomBytes(18).toString('base64url');
32
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Content-Security-Policy': `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'nonce-${nonce}'; connect-src 'self'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'` });
33
+ res.end(usagePage(nonce));
34
+ return;
35
+ }
36
+ if (req.headers.authorization !== 'Bearer ' + token) {
37
+ send(403, { error: 'Forbidden' });
38
+ return;
39
+ }
40
+ if (req.method !== 'GET' || req.url !== '/api/usage') {
41
+ send(404, { error: 'Not found' });
42
+ return;
43
+ }
44
+ try {
45
+ pending ??= readUsage(options);
46
+ send(200, await pending);
47
+ }
48
+ catch {
49
+ send(500, { error: 'Could not read local usage history' });
50
+ }
51
+ finally {
52
+ pending = undefined;
53
+ }
54
+ });
55
+ await new Promise((done, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', () => { server.off('error', reject); done(); }); });
56
+ origin = 'http://127.0.0.1:' + server.address().port;
57
+ return { url: origin + '/#' + token, close: () => new Promise((done, reject) => { server.close(error => error ? reject(error) : done()); server.closeIdleConnections(); }) };
58
+ }
59
+ export async function openUsageWindow(cli, options) {
60
+ // A separate process owns the window so the invoking agent/terminal can continue immediately.
61
+ const args = ['usage', options.overlay ? '--overlay' : '--web', '--no-start', ...(options.provider ? ['--provider', options.provider] : []), ...(options.session ? ['--session', options.session] : [])];
62
+ const child = fork(cli, args, { detached: true, windowsHide: true, stdio: ['ignore', 'ignore', 'ignore', 'ipc'] });
63
+ const url = await new Promise((done, reject) => {
64
+ const timer = setTimeout(() => { child.kill(); reject(Error('Usage window did not start')); }, 30000);
65
+ child.once('error', error => { clearTimeout(timer); reject(error); });
66
+ child.once('exit', () => { clearTimeout(timer); reject(Error('Usage window could not start')); });
67
+ child.once('message', message => { clearTimeout(timer); done(String(message)); });
68
+ });
69
+ child.disconnect();
70
+ child.unref();
71
+ console.log('Relay usage window: ' + url);
72
+ if (!options.noOpen && !options.overlay) {
73
+ const executable = process.platform === 'win32' ? 'rundll32.exe' : process.platform === 'darwin' ? 'open' : 'xdg-open';
74
+ const browser = spawn(executable, process.platform === 'win32' ? ['url.dll,FileProtocolHandler', url] : [url], { windowsHide: true, stdio: 'ignore' });
75
+ browser.on('error', () => console.error('Open the usage link above in your browser.'));
76
+ browser.unref();
77
+ }
78
+ return { url, pid: child.pid };
79
+ }
@@ -0,0 +1,158 @@
1
+ import { createReadStream } from 'node:fs';
2
+ import { readdir } from 'node:fs/promises';
3
+ import { createInterface } from 'node:readline';
4
+ import { homedir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ const empty = () => ({ input: 0, output: 0, cachedInput: 0, cacheWrite: 0, total: 0 });
7
+ const count = (x) => Number.isSafeInteger(x) && Number(x) >= 0;
8
+ const add = (a, b) => { for (const key of Object.keys(a))
9
+ a[key] += b[key]; return a; };
10
+ function counters(raw, provider) {
11
+ if (!raw || !count(raw.input_tokens) || !count(raw.output_tokens))
12
+ return;
13
+ const cached = provider === 'codex' ? raw.cached_input_tokens : raw.cache_read_input_tokens;
14
+ const written = provider === 'codex' ? raw.cache_write_input_tokens : raw.cache_creation_input_tokens;
15
+ if (cached !== undefined && !count(cached) || written !== undefined && !count(written))
16
+ return;
17
+ // Codex includes cached tokens in input; Claude reports three disjoint input buckets.
18
+ const input = raw.input_tokens + (provider === 'claude' ? (cached ?? 0) + (written ?? 0) : 0);
19
+ return { input, output: raw.output_tokens, cachedInput: cached ?? 0, cacheWrite: written ?? 0, total: input + raw.output_tokens };
20
+ }
21
+ export async function readUsage(options = {}) {
22
+ if (options.provider && !['codex', 'claude'].includes(options.provider))
23
+ throw Error('Usage provider must be codex or claude');
24
+ const sessions = new Map(), coverage = { files: 0, unreadable: 0, malformedLines: 0, sessionsWithoutCounters: 0 };
25
+ const scan = async (dir, provider) => {
26
+ let entries;
27
+ try {
28
+ entries = await readdir(dir, { withFileTypes: true });
29
+ }
30
+ catch (error) {
31
+ if (error.code !== 'ENOENT')
32
+ coverage.unreadable++;
33
+ return;
34
+ }
35
+ for (const entry of entries) {
36
+ if (entry.isSymbolicLink())
37
+ continue;
38
+ const path = join(dir, entry.name);
39
+ if (entry.isDirectory()) {
40
+ await scan(path, provider);
41
+ continue;
42
+ }
43
+ if (!entry.isFile() || !entry.name.endsWith('.jsonl'))
44
+ continue;
45
+ coverage.files++;
46
+ let nativeId = provider === 'claude' ? entry.name.slice(0, -6) : undefined;
47
+ const stream = createReadStream(path, { encoding: 'utf8' }), lines = createInterface({ input: stream, crlfDelay: Infinity });
48
+ try {
49
+ for await (const line of lines) {
50
+ if (!line.trim())
51
+ continue;
52
+ // Skip conversation bodies before JSON parsing; only counter records are relevant.
53
+ if (provider === 'codex' && !line.includes('session_meta') && !line.includes('token_count'))
54
+ continue;
55
+ if (provider === 'claude' && !line.includes('"usage"'))
56
+ continue;
57
+ let record;
58
+ try {
59
+ record = JSON.parse(line);
60
+ }
61
+ catch {
62
+ coverage.malformedLines++;
63
+ continue;
64
+ }
65
+ if (provider === 'codex' && record.type === 'session_meta' && typeof record.payload?.id === 'string')
66
+ nativeId = record.payload.id;
67
+ if (provider === 'claude' && typeof record.sessionId === 'string')
68
+ nativeId = record.sessionId;
69
+ if (!nativeId)
70
+ continue;
71
+ const key = provider + ':' + nativeId;
72
+ let session = sessions.get(key);
73
+ if (!session) {
74
+ session = { id: nativeId, provider, samples: new Map(), seenAt: '' };
75
+ sessions.set(key, session);
76
+ }
77
+ const at = typeof record.timestamp === 'string' && Number.isFinite(Date.parse(record.timestamp)) ? new Date(record.timestamp).toISOString() : '';
78
+ const raw = provider === 'codex' ? (record.type === 'event_msg' && record.payload?.type === 'token_count' ? record.payload.info?.total_token_usage : undefined) : (record.type === 'assistant' ? record.message?.usage : undefined);
79
+ const tokens = counters(raw, provider);
80
+ if (!tokens || !at)
81
+ continue;
82
+ const sampleKey = provider === 'codex' ? at : record.message?.id;
83
+ // Claude may repeat an assistant message while streaming; keep its final counters.
84
+ if (typeof sampleKey !== 'string')
85
+ continue;
86
+ const previous = session.samples.get(sampleKey);
87
+ if (!previous || tokens.total >= previous.tokens.total)
88
+ session.samples.set(sampleKey, { at, tokens });
89
+ if (at > session.seenAt)
90
+ session.seenAt = at;
91
+ }
92
+ }
93
+ catch {
94
+ coverage.unreadable++;
95
+ }
96
+ finally {
97
+ lines.close();
98
+ stream.destroy();
99
+ }
100
+ }
101
+ };
102
+ if (!options.provider || options.provider === 'codex') {
103
+ const home = options.codexHome ?? process.env.CODEX_HOME ?? join(homedir(), '.codex');
104
+ await scan(join(home, 'sessions'), 'codex');
105
+ await scan(join(home, 'archived_sessions'), 'codex');
106
+ }
107
+ if (!options.provider || options.provider === 'claude')
108
+ await scan(join(options.claudeHome ?? process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), '.claude'), 'projects'), 'claude');
109
+ const now = options.now ?? new Date(), day = new Date(now);
110
+ day.setHours(0, 0, 0, 0);
111
+ const providers = { codex: { lifetime: empty(), today: empty(), sessions: 0 }, claude: { lifetime: empty(), today: empty(), sessions: 0 } };
112
+ const results = [];
113
+ for (const session of sessions.values()) {
114
+ if (!session.samples.size) {
115
+ coverage.sessionsWithoutCounters++;
116
+ continue;
117
+ }
118
+ const lifetime = empty(), today = empty();
119
+ let previous = empty();
120
+ for (const sample of [...session.samples.values()].sort((a, b) => a.at.localeCompare(b.at))) {
121
+ let delta = sample.tokens;
122
+ if (session.provider === 'codex') {
123
+ delta = empty();
124
+ for (const key of Object.keys(delta))
125
+ delta[key] = Math.max(0, sample.tokens[key] - previous[key]);
126
+ // Repeated snapshots and resumed/archived copies cannot add usage twice.
127
+ for (const key of Object.keys(previous))
128
+ previous[key] = Math.max(previous[key], sample.tokens[key]);
129
+ }
130
+ add(lifetime, delta);
131
+ if (Date.parse(sample.at) >= day.getTime() && Date.parse(sample.at) <= now.getTime())
132
+ add(today, delta);
133
+ }
134
+ add(providers[session.provider].lifetime, lifetime);
135
+ add(providers[session.provider].today, today);
136
+ providers[session.provider].sessions++;
137
+ results.push({ id: session.id, provider: session.provider, lastUsageAt: session.seenAt, tokens: lifetime });
138
+ }
139
+ results.sort((a, b) => b.lastUsageAt.localeCompare(a.lastUsageAt));
140
+ const selected = options.session ? results.find(s => s.id === options.session) : results[0];
141
+ if (options.session && !selected)
142
+ throw Error('No token counters found for that native session ID on this device');
143
+ return { scope: 'Local provider history on this device; includes all agent work, not just Relay messages.', asOf: now.toISOString(), todayStartsAt: day.toISOString(), selection: options.session ? 'requested session' : 'most recently active session with counters', session: selected ?? null, sessions: results, providers, coverage,
144
+ limitations: 'Lifetime covers retained local logs only. Deleted history, other devices, and forks with inherited counters can affect totals. These are recorded token counts, not subscription limits or billed costs.' };
145
+ }
146
+ export function formatUsage(report) {
147
+ const number = (value) => value.toLocaleString('en-US');
148
+ const lines = ['Relay token usage · this device', report.scope, ''];
149
+ if (report.session)
150
+ lines.push(`Session (${report.selection}): ${report.session.provider} ${report.session.id}`, ` ${number(report.session.tokens.total)} total · ${number(report.session.tokens.input)} input · ${number(report.session.tokens.output)} output`);
151
+ else
152
+ lines.push('Session: unavailable — no recorded token counters found.');
153
+ for (const [provider, value] of Object.entries(report.providers))
154
+ if (value.sessions)
155
+ lines.push('', `${provider} · ${value.sessions} sessions`, ` Today: ${number(value.today.total)} tokens (local day)`, ` Lifetime: ${number(value.lifetime.total)} tokens · ${number(value.lifetime.input)} input · ${number(value.lifetime.output)} output`, ` Cached input: ${number(value.lifetime.cachedInput)} · cache writes: ${number(value.lifetime.cacheWrite)} (included in input)`);
156
+ lines.push('', `Coverage: ${report.coverage.files} files · ${report.coverage.unreadable} unreadable · ${report.coverage.malformedLines} malformed/incomplete lines · ${report.coverage.sessionsWithoutCounters} sessions without counters`, report.limitations, '', 'Use relay usage --session NATIVE_ID for a specific conversation; --json for full counters.');
157
+ return lines.join('\n');
158
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@platys/relay",
3
- "version": "0.8.0-alpha.15",
3
+ "version": "0.8.0-alpha.16",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "registry": "https://registry.npmjs.org/",
@@ -39,6 +39,7 @@
39
39
  "test:hosted": "npm run build && node out/tests/hosted-smoke.js",
40
40
  "test:native": "npm run build && node out/tests/native-smoke.js",
41
41
  "test:claude-native": "npm run build && node next/tests/claude-session-native-smoke.mjs",
42
+ "test:usage-overlay": "npm run build && node next/tests/usage-overlay-smoke.mjs",
42
43
  "test:coordination-native": "npm run build && node next/tests/coordination-native-smoke.mjs",
43
44
  "prepack": "npm run build",
44
45
  "worker:dev": "wrangler dev --config next/worker/wrangler.jsonc",