troxy-cli 1.30.4 → 1.30.6
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/bin/troxy.js +0 -7
- package/package.json +1 -1
- package/src/init.js +14 -9
- package/src/mods/troxy-guard/hooks/register.js +20 -3
- package/src/sandbox/posttooluse-hook.js +13 -0
- package/src/tests/claude-code-hook.test.js +12 -0
- package/src/tests/mods-troxy-guard.test.js +21 -0
- package/src/tests/posttooluse-hook.test.js +20 -0
- package/src/install-mod.js +0 -50
- package/src/usage-bar.js +0 -25
package/bin/troxy.js
CHANGED
|
@@ -284,13 +284,6 @@ switch (command) {
|
|
|
284
284
|
break;
|
|
285
285
|
}
|
|
286
286
|
|
|
287
|
-
// usage-bar: Show live % savings in Claude UI
|
|
288
|
-
case 'usage-bar': {
|
|
289
|
-
const { showUsageBar } = await import('../src/usage-bar.js');
|
|
290
|
-
await showUsageBar();
|
|
291
|
-
break;
|
|
292
|
-
}
|
|
293
|
-
|
|
294
287
|
// ── Heartbeat daemon (background service) ─────────────────────
|
|
295
288
|
case 'daemon': {
|
|
296
289
|
const { runDaemon } = await import('../src/daemon.js');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "troxy-cli",
|
|
3
|
-
"version": "1.30.
|
|
3
|
+
"version": "1.30.6",
|
|
4
4
|
"description": "A secure control layer for AI agents: policies across payments, messages, logins, destructive actions, model usage, and secrets, all enforceable from the CLI",
|
|
5
5
|
"homepage": "https://troxy.io",
|
|
6
6
|
"bugs": {
|
package/src/init.js
CHANGED
|
@@ -570,14 +570,6 @@ export async function runInit({ key, name, proxy, concise } = {}) {
|
|
|
570
570
|
// Tool detection is a bonus, never a blocker for a successful init.
|
|
571
571
|
}
|
|
572
572
|
|
|
573
|
-
// Install Troxy usage bar MOD into Claude Code (CLI + desktop)
|
|
574
|
-
try {
|
|
575
|
-
const { installTroxySavingsModToClaudeCode } = await import('./install-mod.js');
|
|
576
|
-
await installTroxySavingsModToClaudeCode();
|
|
577
|
-
} catch {
|
|
578
|
-
// MOD installation is optional - init succeeds even if it fails
|
|
579
|
-
}
|
|
580
|
-
|
|
581
573
|
console.log('\n Troxy is on. Restart Claude Code, then work as usual.');
|
|
582
574
|
console.log(' Your savings → https://dash.troxy.io (Usage and Savings)');
|
|
583
575
|
console.log('\n For more information, visit https://docs.troxy.io\n');
|
|
@@ -906,6 +898,19 @@ export function patchClaudeCodeHooks(
|
|
|
906
898
|
if (postIdx >= 0) config.hooks.PostToolUse[postIdx] = postToolUseEntry;
|
|
907
899
|
else config.hooks.PostToolUse.push(postToolUseEntry);
|
|
908
900
|
|
|
901
|
+
return stripLegacyUsageBarHooks(config);
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
// Older installs added Notification/MessageDisplay hooks that ran `troxy usage-bar`,
|
|
905
|
+
// which printed placeholder numbers. Remove them (and only them) on every patch and unpatch.
|
|
906
|
+
export function stripLegacyUsageBarHooks(config) {
|
|
907
|
+
for (const event of ['Notification', 'MessageDisplay']) {
|
|
908
|
+
if (!Array.isArray(config.hooks?.[event])) continue;
|
|
909
|
+
config.hooks[event] = config.hooks[event]
|
|
910
|
+
.map(e => ({ ...e, hooks: (e.hooks || []).filter(h => !/\btroxy usage-bar\s*$/.test(h.command || '')) }))
|
|
911
|
+
.filter(e => e.hooks.length);
|
|
912
|
+
if (!config.hooks[event].length) delete config.hooks[event];
|
|
913
|
+
}
|
|
909
914
|
return config;
|
|
910
915
|
}
|
|
911
916
|
|
|
@@ -922,7 +927,7 @@ export function unpatchClaudeCodeHooks(config) {
|
|
|
922
927
|
if (Array.isArray(config.hooks?.PostToolUse)) {
|
|
923
928
|
config.hooks.PostToolUse = config.hooks.PostToolUse.filter(e => !_isTroxyPostToolUseEntry(e));
|
|
924
929
|
}
|
|
925
|
-
return config;
|
|
930
|
+
return stripLegacyUsageBarHooks(config);
|
|
926
931
|
}
|
|
927
932
|
|
|
928
933
|
// File-based wrappers (for call sites that operate directly on file paths,
|
|
@@ -230,7 +230,7 @@ const REPORT_KEEP_ROWS = 1000; // kept for the next try while Troxy is unreachab
|
|
|
230
230
|
let pending = [];
|
|
231
231
|
let compacting = false;
|
|
232
232
|
|
|
233
|
-
function reportRow({ sid, usage, requested, arm, cold, score, compact, rateLimits, ts, effort, effortAsked, surfaces }) {
|
|
233
|
+
function reportRow({ sid, usage, requested, arm, cold, score, compact, rateLimits, ts, effort, effortAsked, surfaces, compressed }) {
|
|
234
234
|
const rl = {};
|
|
235
235
|
for (const w of rateLimits || []) {
|
|
236
236
|
if (!WINDOW_LABEL[w.kind]) continue;
|
|
@@ -243,10 +243,26 @@ function reportRow({ sid, usage, requested, arm, cold, score, compact, rateLimit
|
|
|
243
243
|
compact: Boolean(compact), input_tokens: usage.input_tokens, output_tokens: usage.output_tokens,
|
|
244
244
|
cache_read_tokens: usage.cache_read_input_tokens, cache_creation_tokens: usage.cache_creation_input_tokens,
|
|
245
245
|
rate_limits: rl, ts, effort: effort || null, effort_requested: effortAsked || null,
|
|
246
|
+
compressed_tokens: compressed > 0 ? compressed : null,
|
|
246
247
|
surface: (surfaces || []).includes('desktop') ? 'desktop' : (surfaces || []).includes('terminal') ? 'terminal' : null,
|
|
247
248
|
};
|
|
248
249
|
}
|
|
249
250
|
|
|
251
|
+
// Tokens the compression hook kept out of this session since the last report (it appends one
|
|
252
|
+
// line per compressed tool output, see sandbox/posttooluse-hook.js). Each count is reported once.
|
|
253
|
+
const savedSeen = new Map();
|
|
254
|
+
async function compressedSince($, sid) {
|
|
255
|
+
try {
|
|
256
|
+
if (!/^[A-Za-z0-9-]{8,128}$/.test(String(sid))) return 0;
|
|
257
|
+
const home = await $.env.get('HOME');
|
|
258
|
+
const text = await $.fs.read(`${home}/.troxy/sandbox/saved/${sid}.log`);
|
|
259
|
+
const total = String(text).split('\n').reduce((n, l) => n + (Number(l) || 0), 0);
|
|
260
|
+
const delta = Math.max(0, total - (savedSeen.get(sid) || 0));
|
|
261
|
+
savedSeen.set(sid, total);
|
|
262
|
+
return delta;
|
|
263
|
+
} catch { return 0; }
|
|
264
|
+
}
|
|
265
|
+
|
|
250
266
|
async function noteStep($, result, meta) {
|
|
251
267
|
const usage = result && result.usage;
|
|
252
268
|
if (!usage || !usage.model) return;
|
|
@@ -254,7 +270,8 @@ async function noteStep($, result, meta) {
|
|
|
254
270
|
try { rateLimits = (await $.session.usage()).rateLimits; } catch {}
|
|
255
271
|
let surfaces = [];
|
|
256
272
|
try { surfaces = await $.session.surfaces(); } catch {}
|
|
257
|
-
|
|
273
|
+
const sid = await $.session.id();
|
|
274
|
+
pending.push(reportRow({ ...meta, sid, usage, compact: compacting, rateLimits, surfaces, compressed: await compressedSince($, sid), ts: await $.clock.now() }));
|
|
258
275
|
if (pending.length > REPORT_KEEP_ROWS) pending = pending.slice(-REPORT_KEEP_ROWS);
|
|
259
276
|
}
|
|
260
277
|
|
|
@@ -648,4 +665,4 @@ export function register(on) {
|
|
|
648
665
|
}
|
|
649
666
|
|
|
650
667
|
// Exported for tests only - not part of the mod's runtime contract.
|
|
651
|
-
export const _internal = { stepEffort, reportRow, noteStep, flushReport, pendingRows: () => pending, troxyConfig, askedFor, rememberAsked, asked, armCompact, cancelCompact, toDomainAndFields, guardBash, failClosed, parseScore, asRequest, pickModel, troxyCommand, modRouteFile, estimateTokens, MODELS, savedLine, sessionLine, addTurn, withKept, statusText, compactPlan, savingsText, fetchSavings, scorePrompt, routeStep, stepModel, lastSent };
|
|
668
|
+
export const _internal = { compressedSince, stepEffort, reportRow, noteStep, flushReport, pendingRows: () => pending, troxyConfig, askedFor, rememberAsked, asked, armCompact, cancelCompact, toDomainAndFields, guardBash, failClosed, parseScore, asRequest, pickModel, troxyCommand, modRouteFile, estimateTokens, MODELS, savedLine, sessionLine, addTurn, withKept, statusText, compactPlan, savingsText, fetchSavings, scorePrompt, routeStep, stepModel, lastSent };
|
|
@@ -38,6 +38,18 @@ function sessionArm(dir, sessionId, holdoutFraction, randomFn) {
|
|
|
38
38
|
return arm;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
// One line per compression: the tokens it kept out of the conversation (about 4 characters
|
|
42
|
+
// each). The troxy-guard mod reads this per session and reports it, since in the mod-only setup
|
|
43
|
+
// no proxy sees the traffic. Append-only so parallel hook processes never overwrite each other.
|
|
44
|
+
export function recordSaved(dir, sessionId, tokens) {
|
|
45
|
+
if (typeof sessionId !== 'string' || !SESSION_ID_RE.test(sessionId) || !(tokens > 0)) return;
|
|
46
|
+
try {
|
|
47
|
+
const file = path.join(dir, 'saved', `${sessionId}.log`);
|
|
48
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
49
|
+
fs.appendFileSync(file, `${Math.round(tokens)}\n`);
|
|
50
|
+
} catch { /* fails open: a missed count never blocks the tool output */ }
|
|
51
|
+
}
|
|
52
|
+
|
|
41
53
|
function writeState(dir, state) {
|
|
42
54
|
try {
|
|
43
55
|
fs.mkdirSync(dir, { recursive: true });
|
|
@@ -117,6 +129,7 @@ export async function buildHookResponse(toolUseBlock, {
|
|
|
117
129
|
const { handle } = await put(output, { summary, tags: [] }, storeDir);
|
|
118
130
|
writeState(storeDir, 'treated');
|
|
119
131
|
const updatedText = `${summary}\n\n(troxy_recall("${handle}") to fetch the full output)`;
|
|
132
|
+
recordSaved(storeDir, toolUseBlock?.session_id, (output.length - updatedText.length) / 4);
|
|
120
133
|
return {
|
|
121
134
|
hookSpecificOutput: {
|
|
122
135
|
hookEventName: 'PostToolUse',
|
|
@@ -271,3 +271,15 @@ describe('unpatchClaudeCodeHooks', () => {
|
|
|
271
271
|
assert.doesNotThrow(() => unpatchClaudeCodeHooks(cfg));
|
|
272
272
|
});
|
|
273
273
|
});
|
|
274
|
+
|
|
275
|
+
describe('stripLegacyUsageBarHooks', () => {
|
|
276
|
+
it('removes only the old placeholder usage-bar hooks, on patch and unpatch', () => {
|
|
277
|
+
const legacy = { matcher: '*', hooks: [{ type: 'command', command: '/opt/homebrew/bin/troxy usage-bar', timeout: 1 }] };
|
|
278
|
+
const mine = { matcher: '*', hooks: [{ type: 'command', command: 'echo mine' }] };
|
|
279
|
+
const cfg = () => ({ hooks: { Notification: [legacy, mine], MessageDisplay: [legacy] } });
|
|
280
|
+
for (const out of [patchClaudeCodeHooks(cfg(), {}, 'troxy hook-report'), unpatchClaudeCodeHooks(cfg())]) {
|
|
281
|
+
assert.deepEqual(out.hooks.Notification, [mine]);
|
|
282
|
+
assert.equal(out.hooks.MessageDisplay, undefined);
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
});
|
|
@@ -439,3 +439,24 @@ test('reportRow: surface is desktop when the app draws the session, terminal oth
|
|
|
439
439
|
assert.equal(reportRow({ ...base, surfaces: ['terminal'] }).surface, 'terminal');
|
|
440
440
|
assert.equal(reportRow({ ...base, surfaces: [] }).surface, null);
|
|
441
441
|
});
|
|
442
|
+
|
|
443
|
+
test('reportRow carries the tokens compression kept out, null when none', () => {
|
|
444
|
+
const { reportRow } = _internal;
|
|
445
|
+
const usage = { model: 'claude-sonnet-5-5', input_tokens: 1, output_tokens: 1 };
|
|
446
|
+
assert.equal(reportRow({ sid: 's1', usage, compressed: 4200 }).compressed_tokens, 4200);
|
|
447
|
+
assert.equal(reportRow({ sid: 's1', usage, compressed: 0 }).compressed_tokens, null);
|
|
448
|
+
assert.equal(reportRow({ sid: 's1', usage }).compressed_tokens, null);
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
test('compressedSince reports each compression once, per session', async () => {
|
|
452
|
+
const { compressedSince } = _internal;
|
|
453
|
+
const sid = 'cccccccc-1111-2222-3333-444444444444';
|
|
454
|
+
let log = '1000\n500\n';
|
|
455
|
+
const $ = { env: { get: async () => '/home/u' }, fs: { read: async (p) => { assert.ok(p.endsWith(`/.troxy/sandbox/saved/${sid}.log`)); return log; } } };
|
|
456
|
+
assert.equal(await compressedSince($, sid), 1500);
|
|
457
|
+
assert.equal(await compressedSince($, sid), 0);
|
|
458
|
+
log += '250\n';
|
|
459
|
+
assert.equal(await compressedSince($, sid), 250);
|
|
460
|
+
assert.equal(await compressedSince({ ...$, fs: { read: async () => { throw new Error('no file'); } } }, 'dddddddd-1111-2222'), 0);
|
|
461
|
+
assert.equal(await compressedSince($, '../etc/passwd'), 0);
|
|
462
|
+
});
|
|
@@ -223,3 +223,23 @@ test('session_id with path characters is ignored (no file outside the store)', a
|
|
|
223
223
|
{ holdoutFraction: 0.2, randomFn: () => 0, storeDir: dir });
|
|
224
224
|
assert.equal(fs.existsSync(path.join(dir, 'sessions')), false);
|
|
225
225
|
});
|
|
226
|
+
|
|
227
|
+
test('a compression records the tokens it kept out, per session, one line each', async () => {
|
|
228
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-sandbox-'));
|
|
229
|
+
const sid = 'aaaaaaaa-1111-2222-3333-444444444444';
|
|
230
|
+
const opts = { sandboxFraction: 1, holdoutFraction: 0, randomFn: () => 0.5, storeDir: dir };
|
|
231
|
+
const big = 'x'.repeat(8000);
|
|
232
|
+
await buildHookResponse({ session_id: sid, tool_response: bashResult(big) }, opts);
|
|
233
|
+
await buildHookResponse({ session_id: sid, tool_response: bashResult(big) }, opts);
|
|
234
|
+
const lines = fs.readFileSync(path.join(dir, 'saved', `${sid}.log`), 'utf8').trim().split('\n').map(Number);
|
|
235
|
+
assert.equal(lines.length, 2);
|
|
236
|
+
assert.ok(lines.every(n => n > 1000 && n <= 2000)); // about 8000 chars / 4, minus the excerpt
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test('no session id or a small output records nothing', async () => {
|
|
240
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-sandbox-'));
|
|
241
|
+
const opts = { sandboxFraction: 1, holdoutFraction: 0, randomFn: () => 0.5, storeDir: dir };
|
|
242
|
+
await buildHookResponse({ tool_response: bashResult('x'.repeat(8000)) }, opts);
|
|
243
|
+
await buildHookResponse({ session_id: 'bbbbbbbb-1111-2222-3333-444444444444', tool_response: bashResult('short') }, opts);
|
|
244
|
+
assert.equal(fs.existsSync(path.join(dir, 'saved')), false);
|
|
245
|
+
});
|
package/src/install-mod.js
DELETED
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
import fs from 'fs';
|
|
2
|
-
import path from 'path';
|
|
3
|
-
import os from 'os';
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Install Troxy policy MOD into Claude Code
|
|
7
|
-
* Adds/updates hooks to show live usage bar with % context saved
|
|
8
|
-
*/
|
|
9
|
-
export async function installTroxySavingsModToClaudeCode() {
|
|
10
|
-
const claudeHome = path.join(os.homedir(), '.claude');
|
|
11
|
-
const settingsPath = path.join(claudeHome, 'settings.json');
|
|
12
|
-
|
|
13
|
-
if (!fs.existsSync(settingsPath)) {
|
|
14
|
-
console.error(' Error: Claude Code not found. Install Claude Code first.\n');
|
|
15
|
-
return false;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
try {
|
|
19
|
-
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
|
20
|
-
|
|
21
|
-
// Add Notification hook to show usage bar after each response
|
|
22
|
-
if (!settings.hooks) settings.hooks = {};
|
|
23
|
-
if (!settings.hooks.Notification) settings.hooks.Notification = [];
|
|
24
|
-
|
|
25
|
-
// Check if already installed
|
|
26
|
-
const alreadyInstalled = settings.hooks.Notification.some(h =>
|
|
27
|
-
h.hooks?.[0]?.command?.includes('troxy usage-bar')
|
|
28
|
-
);
|
|
29
|
-
|
|
30
|
-
if (!alreadyInstalled) {
|
|
31
|
-
settings.hooks.Notification.push({
|
|
32
|
-
matcher: '*',
|
|
33
|
-
hooks: [
|
|
34
|
-
{
|
|
35
|
-
type: 'command',
|
|
36
|
-
command: '/opt/homebrew/bin/troxy usage-bar',
|
|
37
|
-
timeout: 1
|
|
38
|
-
}
|
|
39
|
-
]
|
|
40
|
-
});
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
|
|
44
|
-
console.log(' ✓ Troxy usage bar MOD installed');
|
|
45
|
-
return true;
|
|
46
|
-
} catch (err) {
|
|
47
|
-
console.error(` Error installing MOD: ${err.message}`);
|
|
48
|
-
return false;
|
|
49
|
-
}
|
|
50
|
-
}
|
package/src/usage-bar.js
DELETED
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Show live usage bar with % context saved
|
|
3
|
-
* Displays in Claude UI as AbovePrompt band
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
export async function showUsageBar() {
|
|
7
|
-
// Read current session usage from Claude's session context
|
|
8
|
-
// Format for AbovePrompt hook output: simple text that Claude displays
|
|
9
|
-
|
|
10
|
-
// Placeholder data - would be fetched from real session in production
|
|
11
|
-
const contextUsed = 45; // %
|
|
12
|
-
const saved = 15; // % saved by Troxy routing/trimming
|
|
13
|
-
|
|
14
|
-
const barLength = 20;
|
|
15
|
-
const filledLength = Math.round((contextUsed / 100) * barLength);
|
|
16
|
-
const emptyLength = barLength - filledLength;
|
|
17
|
-
|
|
18
|
-
const bar = '█'.repeat(filledLength) + '░'.repeat(emptyLength);
|
|
19
|
-
|
|
20
|
-
// Output format for Claude's hook system
|
|
21
|
-
// This gets displayed in the AbovePrompt area
|
|
22
|
-
const output = `[${bar}] ${contextUsed}% used · ${saved}% saved by Troxy`;
|
|
23
|
-
|
|
24
|
-
console.log(output);
|
|
25
|
-
}
|