troxy-cli 1.29.41 → 1.29.43
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "troxy-cli",
|
|
3
|
-
"version": "1.29.
|
|
3
|
+
"version": "1.29.43",
|
|
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
|
@@ -31,6 +31,10 @@ export async function installTroxyGuardMod(home = os.homedir(), detectFn = detec
|
|
|
31
31
|
const destDir = path.join(home, '.claude', 'skills', 'troxy-guard');
|
|
32
32
|
fs.rmSync(destDir, { recursive: true, force: true });
|
|
33
33
|
fs.cpSync(TROXY_GUARD_MOD_SOURCE_DIR, destDir, { recursive: true });
|
|
34
|
+
// The mod can't see the CLI's version on its own; /troxy status reads it from here.
|
|
35
|
+
const manifest = path.join(destDir, '.claude-plugin', 'plugin.json');
|
|
36
|
+
const { version: troxyVersion } = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
|
|
37
|
+
fs.writeFileSync(manifest, JSON.stringify({ ...JSON.parse(fs.readFileSync(manifest, 'utf8')), version: troxyVersion }, null, 2) + '\n');
|
|
34
38
|
return true;
|
|
35
39
|
}
|
|
36
40
|
|
|
@@ -157,6 +157,21 @@ function sessionLine(t) {
|
|
|
157
157
|
Object.entries(t.meter).map(([kind, percentUsed]) => ({ kind, percentUsed })));
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
+
// /troxy status: everything about this session's Troxy in one place.
|
|
161
|
+
function statusText({ version, paused, totals, rateLimits = [], compactOff = false }) {
|
|
162
|
+
const lines = [`Troxy ${version || ''}`.trim() + ` · routing is ${paused ? 'off' : 'on'}`];
|
|
163
|
+
lines.push(sessionLine(totals) ? `This session: ${sessionLine(totals).replace(/^.*?This session: /, '')}`
|
|
164
|
+
: 'This session: nothing moved to a cheaper model yet');
|
|
165
|
+
const meter = rateLimits.filter(w => WINDOW_LABEL[w.kind]).map(w => `${WINDOW_LABEL[w.kind]} ${Math.round(w.percentUsed)}%`);
|
|
166
|
+
if (meter.length) lines.push(`Plan usage now: ${meter.join(' · ')}`);
|
|
167
|
+
lines.push(`Prompts with over ${ROUTE_CONTEXT_FLOOR_TOKENS / 1000}k tokens of context stay on your model (switching would rewrite the cache).`);
|
|
168
|
+
lines.push(compactOff ? 'Idle compaction is off (/troxy compact on).'
|
|
169
|
+
: `Idle compaction is on: sessions over ${COMPACT_MIN_TOKENS / 1000}k tokens are compacted after ${COMPACT_IDLE_MS / 60000} min idle (/troxy compact off).`);
|
|
170
|
+
lines.push('All sessions: dash.troxy.io');
|
|
171
|
+
lines.push(paused ? 'Type /troxy on to resume.' : 'Type /troxy off to pause routing.');
|
|
172
|
+
return lines.join('\n');
|
|
173
|
+
}
|
|
174
|
+
|
|
160
175
|
async function snapshot($) {
|
|
161
176
|
const u = await $.session.usage();
|
|
162
177
|
const meter = {};
|
|
@@ -177,6 +192,50 @@ function addTurn(totals, start, end, ratio) {
|
|
|
177
192
|
return { paid: t.paid + paid, full: t.full + paid * ratio, meter };
|
|
178
193
|
}
|
|
179
194
|
|
|
195
|
+
// Idle compaction: shrink a big session just before its prompt cache expires (1h), so
|
|
196
|
+
// the summary reads the conversation from cache (~0.1x) and the user comes back to a
|
|
197
|
+
// small session instead of re-sending the whole thing. Never while the plan meter is
|
|
198
|
+
// nearly full: then it waits for the window to reset and compacts on a fresh bar.
|
|
199
|
+
const COMPACT_IDLE_MS = 55 * 60 * 1000;
|
|
200
|
+
const COMPACT_MIN_TOKENS = 100000;
|
|
201
|
+
const COMPACT_LIMIT_PCT = 95;
|
|
202
|
+
const COMPACT_AFTER_RESET_MS = 60 * 1000;
|
|
203
|
+
const COMPACT_INSTRUCTIONS = 'Keep the current task, the files involved, decisions made, and open to-dos.';
|
|
204
|
+
const COMPACTED = { plugin: 'troxy-guard', key: 'compacted' }; // the band's note until the next prompt
|
|
205
|
+
let compactTimer = null;
|
|
206
|
+
|
|
207
|
+
// What to do when the idle timer fires: skip, compact now, or wait for the meter to reset.
|
|
208
|
+
function compactPlan({ tokens, rateLimits = [], now, off }) {
|
|
209
|
+
if (off || !(tokens >= COMPACT_MIN_TOKENS)) return { action: 'skip' };
|
|
210
|
+
const full = rateLimits.filter(w => WINDOW_LABEL[w.kind] && w.percentUsed >= COMPACT_LIMIT_PCT);
|
|
211
|
+
if (!full.length) return { action: 'compact' };
|
|
212
|
+
const resets = full.map(w => Date.parse(w.resetsAt)).filter(Number.isFinite);
|
|
213
|
+
if (resets.length < full.length) return { action: 'skip' }; // unknown reset: never spend a nearly full bar
|
|
214
|
+
return { action: 'wait', inMs: Math.max(0, Math.max(...resets) - now) + COMPACT_AFTER_RESET_MS };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function cancelCompact() {
|
|
218
|
+
if (compactTimer) compactTimer.cancel();
|
|
219
|
+
compactTimer = null;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function armCompact($, ms) {
|
|
223
|
+
cancelCompact();
|
|
224
|
+
compactTimer = $.clock.after(ms, () => { compactTimer = null; idleCompact($).catch(() => {}); });
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function idleCompact($) {
|
|
228
|
+
const u = await $.session.usage();
|
|
229
|
+
const plan = compactPlan({ tokens: u.context && u.context.tokens, rateLimits: u.rateLimits, now: await $.clock.now(), off: await $.store.get('compactOff') });
|
|
230
|
+
if (plan.action === 'wait') return armCompact($, plan.inMs);
|
|
231
|
+
if (plan.action !== 'compact') return;
|
|
232
|
+
const r = await $.session.compact({ instructions: COMPACT_INSTRUCTIONS });
|
|
233
|
+
if (r.skip) return;
|
|
234
|
+
const k = n => `${Math.round(n / 1000)}k`;
|
|
235
|
+
await $.state.set(COMPACTED, r.tokensBefore && r.tokensAfter
|
|
236
|
+
? `compacted this idle session ${k(r.tokensBefore)} → ${k(r.tokensAfter)} tokens` : 'compacted this idle session');
|
|
237
|
+
}
|
|
238
|
+
|
|
180
239
|
// Where the local daemon looks for this session's routing note (see interceptor.js).
|
|
181
240
|
// Only when installed under ~/.claude/skills, so a --plugin-dir test run never writes
|
|
182
241
|
// into a real user's folder.
|
|
@@ -195,6 +254,8 @@ let decision = null;
|
|
|
195
254
|
|
|
196
255
|
async function scorePrompt($, e, next) {
|
|
197
256
|
decision = null;
|
|
257
|
+
cancelCompact();
|
|
258
|
+
if ((await $.state.get(COMPACTED)).value) await $.state.set(COMPACTED, null);
|
|
198
259
|
turn = { start: await snapshot($), ratio: 1 };
|
|
199
260
|
const sid = await $.session.id();
|
|
200
261
|
const file = modRouteFile($.plugin.root, sid);
|
|
@@ -241,12 +302,21 @@ export function register(on) {
|
|
|
241
302
|
const file = modRouteFile($.plugin.root, sid);
|
|
242
303
|
if (file) { lastNote = `${sid}:{}`; await $.fs.write(file, '{}'); }
|
|
243
304
|
$.ui.status(undefined); // the bar moved to the band above the prompt
|
|
305
|
+
armCompact($, COMPACT_IDLE_MS); // a hot reload cancels the old timer
|
|
244
306
|
return next(e);
|
|
245
307
|
});
|
|
246
308
|
on('command.run', { command: 'troxy' }, async ($, e) => {
|
|
309
|
+
const c = /^\s*compact\s+(on|off)\s*$/i.exec(String(e.args || ''));
|
|
310
|
+
if (c) {
|
|
311
|
+
await $.store.set('compactOff', c[1].toLowerCase() === 'off');
|
|
312
|
+
return { text: c[1].toLowerCase() === 'off' ? 'Troxy idle compaction is off.' : 'Troxy idle compaction is on.' };
|
|
313
|
+
}
|
|
247
314
|
const r = troxyCommand(e.args, Boolean(await $.store.get('paused')));
|
|
248
315
|
await $.store.set('paused', r.paused);
|
|
249
|
-
return { text: r.text };
|
|
316
|
+
if (/^\s*(on|off)\s*$/i.test(String(e.args || ''))) return { text: r.text };
|
|
317
|
+
const manifest = JSON.parse(await $.fs.read(`${$.plugin.root}/.claude-plugin/plugin.json`).catch(() => '{}'));
|
|
318
|
+
const { value: totals } = await $.state.get(SAVED);
|
|
319
|
+
return { text: statusText({ version: manifest.version, paused: r.paused, totals, rateLimits: (await $.session.usage()).rateLimits, compactOff: Boolean(await $.store.get('compactOff')) }) };
|
|
250
320
|
});
|
|
251
321
|
// Routing fails open: a scorer or hook error must never break the session,
|
|
252
322
|
// it just means the requested model is used as-is.
|
|
@@ -254,9 +324,10 @@ export function register(on) {
|
|
|
254
324
|
on('turn.step', routeStep).catch(async function* ($, e, next) { return yield* next(e); });
|
|
255
325
|
on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
|
|
256
326
|
const { value: line } = await $.state.get(LINE); // subscribes the band to the line
|
|
257
|
-
|
|
327
|
+
const { value: note } = await $.state.get(COMPACTED);
|
|
328
|
+
if ((!line && !note) || e.props.hasSurvey) return next(e);
|
|
258
329
|
const { Text } = $.ui.resolve(e);
|
|
259
|
-
return h(Text, { color: TROXY_TEAL }, `Troxy ${line}`);
|
|
330
|
+
return h(Text, { color: TROXY_TEAL }, `Troxy ${[line, note].filter(Boolean).join(' · ')}`);
|
|
260
331
|
});
|
|
261
332
|
// After the answer, so counting never delays a prompt.
|
|
262
333
|
on('turn.complete', async ($, e, next) => {
|
|
@@ -272,9 +343,10 @@ export function register(on) {
|
|
|
272
343
|
if (line) await $.state.set(LINE, line);
|
|
273
344
|
} catch {} // a missed turn only leaves the band as it was
|
|
274
345
|
}
|
|
346
|
+
armCompact($, COMPACT_IDLE_MS);
|
|
275
347
|
return r;
|
|
276
348
|
});
|
|
277
349
|
}
|
|
278
350
|
|
|
279
351
|
// Exported for tests only - not part of the mod's runtime contract.
|
|
280
|
-
export const _internal = { toDomainAndFields, guardBash, failClosed, parseScore, pickModel, troxyCommand, modRouteFile, estimateTokens, MODELS, savedLine, sessionLine, addTurn };
|
|
352
|
+
export const _internal = { toDomainAndFields, guardBash, failClosed, parseScore, pickModel, troxyCommand, modRouteFile, estimateTokens, MODELS, savedLine, sessionLine, addTurn, statusText, compactPlan };
|
|
@@ -3,6 +3,6 @@ export type SessionSaved = { paid: number; full: number; meter: Record<string, n
|
|
|
3
3
|
|
|
4
4
|
declare module 'claude-code' {
|
|
5
5
|
interface PluginState {
|
|
6
|
-
'troxy-guard': { line: BarLine; saved: SessionSaved }
|
|
6
|
+
'troxy-guard': { line: BarLine; saved: SessionSaved; compacted: BarLine }
|
|
7
7
|
}
|
|
8
8
|
}
|
|
@@ -12,6 +12,9 @@ test('installTroxyGuardMod: copies the mod when the version supports it', async
|
|
|
12
12
|
assert.equal(installed, true);
|
|
13
13
|
assert.ok(fs.existsSync(path.join(tmpHome, '.claude', 'skills', 'troxy-guard', '.claude-plugin', 'plugin.json')));
|
|
14
14
|
assert.ok(fs.existsSync(path.join(tmpHome, '.claude', 'skills', 'troxy-guard', 'hooks', 'register.js')));
|
|
15
|
+
// stamped with the CLI's version, which /troxy status shows
|
|
16
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(tmpHome, '.claude', 'skills', 'troxy-guard', '.claude-plugin', 'plugin.json'), 'utf8'));
|
|
17
|
+
assert.equal(manifest.version, JSON.parse(fs.readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version);
|
|
15
18
|
} finally {
|
|
16
19
|
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
17
20
|
}
|
|
@@ -90,3 +90,31 @@ test('addTurn: a session total from its own turns, a routed one counted at the a
|
|
|
90
90
|
// No routed turn yet: no band.
|
|
91
91
|
assert.equal(sessionLine(addTurn(undefined, { cost: 0, meter: {} }, { cost: 1, meter: {} }, 1)), undefined);
|
|
92
92
|
});
|
|
93
|
+
|
|
94
|
+
test('statusText: version, routing, this session, plan usage, where the account view is', () => {
|
|
95
|
+
const { statusText } = _internal;
|
|
96
|
+
const totals = { paid: 0.03, full: 0.12, meter: {} };
|
|
97
|
+
assert.equal(statusText({ version: '1.29.42', paused: false, totals, rateLimits: [{ kind: 'five_hour', percentUsed: 23.4 }, { kind: 'seven_day', percentUsed: 77 }] }),
|
|
98
|
+
'Troxy 1.29.42 · routing is on\n'
|
|
99
|
+
+ 'This session: your bar lasts 4.0x longer · $0.09 saved of $0.12 at list price · estimate\n'
|
|
100
|
+
+ 'Plan usage now: 5h 23% · 7d 77%\n'
|
|
101
|
+
+ 'Prompts with over 50k tokens of context stay on your model (switching would rewrite the cache).\n'
|
|
102
|
+
+ 'Idle compaction is on: sessions over 100k tokens are compacted after 55 min idle (/troxy compact off).\n'
|
|
103
|
+
+ 'All sessions: dash.troxy.io\n'
|
|
104
|
+
+ 'Type /troxy off to pause routing.');
|
|
105
|
+
assert.match(statusText({ paused: true }), /^Troxy · routing is off\nThis session: nothing moved to a cheaper model yet\n/);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('compactPlan: big idle sessions compact, small ones and a nearly full bar do not', () => {
|
|
109
|
+
const { compactPlan } = _internal;
|
|
110
|
+
const now = Date.parse('2026-10-08T15:00:00Z');
|
|
111
|
+
assert.deepEqual(compactPlan({ tokens: 210000, rateLimits: [{ kind: 'five_hour', percentUsed: 40 }], now }), { action: 'compact' });
|
|
112
|
+
assert.deepEqual(compactPlan({ tokens: 40000, now }), { action: 'skip' });
|
|
113
|
+
assert.deepEqual(compactPlan({ tokens: 210000, now, off: true }), { action: 'skip' });
|
|
114
|
+
// 5h at 95%, resets in 2h; 7d at 95%, resets in 3h: wait for the later one, plus a minute
|
|
115
|
+
assert.deepEqual(compactPlan({ tokens: 210000, now, rateLimits: [
|
|
116
|
+
{ kind: 'five_hour', percentUsed: 95, resetsAt: '2026-10-08T17:00:00Z' },
|
|
117
|
+
{ kind: 'seven_day', percentUsed: 95, resetsAt: '2026-10-08T18:00:00Z' }] }), { action: 'wait', inMs: 3 * 3600000 + 60000 });
|
|
118
|
+
// nearly full with no known reset: never spend it
|
|
119
|
+
assert.deepEqual(compactPlan({ tokens: 210000, now, rateLimits: [{ kind: 'seven_day', percentUsed: 99 }] }), { action: 'skip' });
|
|
120
|
+
});
|