troxy-cli 1.29.49 → 1.29.51
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.51",
|
|
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": {
|
|
@@ -269,20 +269,59 @@ function compactPlan({ tokens, rateLimits = [], now, off }) {
|
|
|
269
269
|
return { action: 'wait', inMs: Math.max(0, Math.max(...resets) - now) + COMPACT_AFTER_RESET_MS };
|
|
270
270
|
}
|
|
271
271
|
|
|
272
|
+
let awake = null;
|
|
273
|
+
|
|
272
274
|
function cancelCompact() {
|
|
273
275
|
if (compactTimer) compactTimer.cancel();
|
|
274
276
|
compactTimer = null;
|
|
277
|
+
stopAwake();
|
|
275
278
|
}
|
|
276
279
|
|
|
277
|
-
|
|
280
|
+
// stayAwake: hold off system sleep (the screen still sleeps) until the timer fires, so a
|
|
281
|
+
// big session compacts on time instead of when the user is back. macOS caffeinate; where
|
|
282
|
+
// it is missing the spawn fails quietly and compact-on-return in scorePrompt covers it.
|
|
283
|
+
function armCompact($, ms, { stayAwake = false, persist = false } = {}) {
|
|
278
284
|
cancelCompact();
|
|
279
|
-
|
|
285
|
+
if (stayAwake) keepAwake($, ms + 2 * 60 * 1000);
|
|
286
|
+
// Persisted so a restart (which loses lastSent and the timer) still finds the idle start.
|
|
287
|
+
if (persist) try {
|
|
288
|
+
Promise.all([$.session.id(), $.clock.now()])
|
|
289
|
+
.then(([sid, at]) => $.store.set(`idle_started_${sid}`, at)).catch(() => {});
|
|
290
|
+
} catch {}
|
|
291
|
+
compactTimer = $.clock.after(ms, () => {
|
|
292
|
+
compactTimer = null;
|
|
293
|
+
idleCompact($).catch(() => {}).finally(stopAwake);
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ponytail: -t caps it, so if return() cannot kill the child it still ends on its own.
|
|
298
|
+
function keepAwake($, ms) {
|
|
299
|
+
stopAwake();
|
|
300
|
+
try {
|
|
301
|
+
const it = $.process.spawn({ argv: ['caffeinate', '-i', '-t', String(Math.ceil(ms / 1000))] })[Symbol.asyncIterator]();
|
|
302
|
+
awake = it;
|
|
303
|
+
it.next().catch(() => {}).finally(() => { if (awake === it) awake = null; });
|
|
304
|
+
} catch {}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function stopAwake() {
|
|
308
|
+
const it = awake;
|
|
309
|
+
awake = null;
|
|
310
|
+
if (it && it.return) Promise.resolve(it.return()).catch(() => {});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async function bigSession($) {
|
|
314
|
+
if (await $.store.get('compactOff')) return false;
|
|
315
|
+
const u = await $.session.usage();
|
|
316
|
+
return ((u.context && u.context.tokens) || 0) >= COMPACT_MIN_TOKENS;
|
|
280
317
|
}
|
|
281
318
|
|
|
282
|
-
|
|
319
|
+
// late: the user is back after a long idle (the Mac may have slept through the timer),
|
|
320
|
+
// so compact now or not at all: never hold their prompt waiting for a meter reset.
|
|
321
|
+
async function idleCompact($, { late = false } = {}) {
|
|
283
322
|
const u = await $.session.usage();
|
|
284
323
|
const plan = compactPlan({ tokens: u.context && u.context.tokens, rateLimits: u.rateLimits, now: await $.clock.now(), off: await $.store.get('compactOff') });
|
|
285
|
-
if (plan.action === 'wait') return armCompact($, plan.inMs);
|
|
324
|
+
if (plan.action === 'wait' && !late) return armCompact($, plan.inMs);
|
|
286
325
|
if (plan.action !== 'compact') return;
|
|
287
326
|
const r = await $.session.compact({ instructions: COMPACT_INSTRUCTIONS });
|
|
288
327
|
if (r.skip) return;
|
|
@@ -311,8 +350,20 @@ async function scorePrompt($, e, next) {
|
|
|
311
350
|
decision = null;
|
|
312
351
|
cancelCompact();
|
|
313
352
|
if ((await $.state.get(COMPACTED)).value) await $.state.set(COMPACTED, null);
|
|
314
|
-
turn = { start: await snapshot($), ratio: 1 };
|
|
315
353
|
const sid = await $.session.id();
|
|
354
|
+
// Idle past the timer by the wall clock: a sleeping Mac freezes the timer and a restart
|
|
355
|
+
// loses it, so the compaction it missed runs here, before this prompt goes out.
|
|
356
|
+
const now = await $.clock.now();
|
|
357
|
+
const ls = lastSent.get(sid);
|
|
358
|
+
let idleSince = ls ? ls.at : null;
|
|
359
|
+
try {
|
|
360
|
+
const stored = await $.store.get(`idle_started_${sid}`);
|
|
361
|
+
if (typeof stored === 'number' && (idleSince == null || stored > idleSince)) idleSince = stored;
|
|
362
|
+
} catch {}
|
|
363
|
+
if (idleSince != null && now - idleSince >= COMPACT_IDLE_MS) {
|
|
364
|
+
try { await idleCompact($, { late: true }); } catch {} // fail open: send the prompt as-is
|
|
365
|
+
}
|
|
366
|
+
turn = { start: await snapshot($), ratio: 1 };
|
|
316
367
|
const file = modRouteFile($.plugin.root, sid);
|
|
317
368
|
if (file && lastNote !== `${sid}:{}`) { lastNote = `${sid}:{}`; await $.fs.write(file, '{}'); } // clear the previous prompt's note
|
|
318
369
|
if (await $.store.get('paused')) return next(e);
|
|
@@ -339,6 +390,23 @@ const coldNext = new Set();
|
|
|
339
390
|
// doesn't route mod sessions either), so treated vs holdout is a clean A/B.
|
|
340
391
|
const ARM = { plugin: 'troxy-guard', key: 'arm' };
|
|
341
392
|
const MOD_HOLDOUT_FRACTION = 0.1;
|
|
393
|
+
// The model the user asked for and the one that ran, per session, kept across restarts.
|
|
394
|
+
// The transcript records only the model that ran, so `claude --resume` reopens a routed
|
|
395
|
+
// session on Haiku; without this the mod would take Haiku as the user's pick for good.
|
|
396
|
+
// ponytail: a /model to the same model already showing is invisible, so it stays mapped.
|
|
397
|
+
const asked = new Map();
|
|
398
|
+
async function askedFor($, sid, model, switched) {
|
|
399
|
+
if (switched) asked.set(sid, null);
|
|
400
|
+
if (!asked.has(sid)) asked.set(sid, (await $.store.get(`asked:${sid}`)) || null);
|
|
401
|
+
const r = asked.get(sid);
|
|
402
|
+
return r && r.ran === model && r.asked !== model ? r.asked : model;
|
|
403
|
+
}
|
|
404
|
+
async function rememberAsked($, sid, want, ran) {
|
|
405
|
+
const r = asked.get(sid);
|
|
406
|
+
if (r && r.asked === want && r.ran === ran) return;
|
|
407
|
+
asked.set(sid, { asked: want, ran });
|
|
408
|
+
await $.store.set(`asked:${sid}`, { asked: want, ran });
|
|
409
|
+
}
|
|
342
410
|
|
|
343
411
|
// Which model a step runs on. Decide only at a cold moment (first step, 55+ min
|
|
344
412
|
// idle, after a compaction): the cache is cold anyway, so any model is free to
|
|
@@ -360,9 +428,11 @@ async function* routeStep($, e, next) {
|
|
|
360
428
|
if (!arm) { arm = Math.random() < MOD_HOLDOUT_FRACTION ? 'holdout' : 'treated'; await $.state.set(ARM, arm); }
|
|
361
429
|
const prev = lastAsked.get(sid);
|
|
362
430
|
lastAsked.set(sid, e.model);
|
|
431
|
+
const switched = Boolean(prev && prev !== e.model);
|
|
432
|
+
const want = await askedFor($, sid, e.model, switched);
|
|
363
433
|
const ls = lastSent.get(sid);
|
|
364
|
-
const model = (await $.store.get('paused')) ?
|
|
365
|
-
arm, requested:
|
|
434
|
+
const model = (await $.store.get('paused')) ? want : stepModel({
|
|
435
|
+
arm, requested: want, switched,
|
|
366
436
|
cold: !ls || now - ls.at >= WARM_MS || coldNext.has(sid),
|
|
367
437
|
score: decision ? decision.score : null, warm: ls && ls.model,
|
|
368
438
|
});
|
|
@@ -370,12 +440,13 @@ async function* routeStep($, e, next) {
|
|
|
370
440
|
lastSent.set(sid, { model, at: now });
|
|
371
441
|
// The daemon tags every call with the arm, plus from/to when the model changed.
|
|
372
442
|
const file = modRouteFile($.plugin.root, sid);
|
|
373
|
-
const key = `${sid}:${arm}:${
|
|
443
|
+
const key = `${sid}:${arm}:${want}>${model}`;
|
|
374
444
|
if (file && lastNote !== key) {
|
|
375
445
|
lastNote = key;
|
|
376
|
-
await $.fs.write(file, JSON.stringify(model ===
|
|
446
|
+
await $.fs.write(file, JSON.stringify(model === want ? { arm, ts: now } : { arm, from: want, to: model, ts: now }));
|
|
377
447
|
}
|
|
378
|
-
|
|
448
|
+
try { await rememberAsked($, sid, want, model); } catch {} // a missed write only loses this on resume
|
|
449
|
+
if (model !== want && turn) turn.ratio = PRICE[tierOf(want)] / PRICE[tierOf(model)];
|
|
379
450
|
return yield* next(model === e.model ? e : { ...e, model });
|
|
380
451
|
}
|
|
381
452
|
|
|
@@ -440,10 +511,12 @@ export function register(on) {
|
|
|
440
511
|
const text = savingsText(await fetchSavings($));
|
|
441
512
|
if (text) await $.state.set(SAVINGS, text);
|
|
442
513
|
} catch {} // a missed fetch only leaves the band as it was
|
|
443
|
-
|
|
514
|
+
let stayAwake = false;
|
|
515
|
+
try { stayAwake = await bigSession($); } catch {}
|
|
516
|
+
armCompact($, COMPACT_IDLE_MS, { stayAwake, persist: true });
|
|
444
517
|
return r;
|
|
445
518
|
});
|
|
446
519
|
}
|
|
447
520
|
|
|
448
521
|
// Exported for tests only - not part of the mod's runtime contract.
|
|
449
|
-
export const _internal = { toDomainAndFields, guardBash, failClosed, parseScore, asRequest, pickModel, troxyCommand, modRouteFile, estimateTokens, MODELS, savedLine, sessionLine, addTurn, statusText, compactPlan, savingsText, fetchSavings, scorePrompt, routeStep, stepModel };
|
|
522
|
+
export const _internal = { askedFor, rememberAsked, asked, armCompact, cancelCompact, toDomainAndFields, guardBash, failClosed, parseScore, asRequest, pickModel, troxyCommand, modRouteFile, estimateTokens, MODELS, savedLine, sessionLine, addTurn, statusText, compactPlan, savingsText, fetchSavings, scorePrompt, routeStep, stepModel };
|
|
@@ -213,3 +213,82 @@ test('pickModel: no score never causes a switch away from the warm model past th
|
|
|
213
213
|
assert.equal(pickModel(null, MODELS.opus, 1000, MODELS.haiku), MODELS.haiku); // small context too
|
|
214
214
|
assert.equal(pickModel(null, MODELS.opus, 1000, null), MODELS.opus); // nothing warm: as asked
|
|
215
215
|
});
|
|
216
|
+
|
|
217
|
+
test('scorePrompt: back after 55+ min by the wall clock (Mac slept through the timer), a big session compacts first', async () => {
|
|
218
|
+
const $ = routingApi();
|
|
219
|
+
let now = 0, compacts = 0, tokens = 174000;
|
|
220
|
+
$.clock.now = async () => now;
|
|
221
|
+
$.session.usage = async () => ({ context: { tokens }, rateLimits: [] });
|
|
222
|
+
$.session.compact = async () => { compacts++; return { tokensBefore: 174000, tokensAfter: 20000 }; };
|
|
223
|
+
const { opus } = _internal.MODELS;
|
|
224
|
+
await runTurn($, opus); // first prompt: nothing to compact
|
|
225
|
+
now += 30 * 60 * 1000; await runTurn($, opus);
|
|
226
|
+
assert.equal(compacts, 0); // 30 min idle: cache still warm
|
|
227
|
+
now += 56 * 60 * 1000; await runTurn($, opus);
|
|
228
|
+
assert.equal(compacts, 1); // 56 min idle: compacted before the prompt
|
|
229
|
+
tokens = 50000; now += 2 * 60 * 60 * 1000; await runTurn($, opus);
|
|
230
|
+
assert.equal(compacts, 1); // small session: left alone
|
|
231
|
+
$.session.compact = async () => { throw new Error('turn running'); };
|
|
232
|
+
tokens = 174000; now += 2 * 60 * 60 * 1000;
|
|
233
|
+
assert.ok(await runTurn($, opus)); // a failed compaction never blocks the prompt
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test('scorePrompt: idle 55+ min across a Claude Code restart still compacts, from the saved idle start', async () => {
|
|
237
|
+
const { armCompact, cancelCompact } = _internal;
|
|
238
|
+
const $ = routingApi();
|
|
239
|
+
const db = new Map();
|
|
240
|
+
let now = 1000, compacts = 0;
|
|
241
|
+
$.store = { get: async k => db.get(k), set: async (k, v) => { db.set(k, v); } };
|
|
242
|
+
$.clock.now = async () => now;
|
|
243
|
+
$.clock.after = () => ({ cancel() {} });
|
|
244
|
+
$.session.usage = async () => ({ context: { tokens: 174000 }, rateLimits: [] });
|
|
245
|
+
$.session.compact = async () => { compacts++; return { tokensBefore: 174000, tokensAfter: 20000 }; };
|
|
246
|
+
armCompact($, 55 * 60 * 1000, { persist: true }); // end of the last turn before the restart
|
|
247
|
+
await new Promise(r => setImmediate(r));
|
|
248
|
+
assert.equal(typeof [...db.values()][0], 'number');
|
|
249
|
+
now += 40 * 60 * 1000;
|
|
250
|
+
armCompact($, 55 * 60 * 1000); // session.start after the restart: must not reset it
|
|
251
|
+
await new Promise(r => setImmediate(r));
|
|
252
|
+
now += 16 * 60 * 1000;
|
|
253
|
+
await runTurn($, _internal.MODELS.opus); // fresh process: no lastSent, only the store
|
|
254
|
+
cancelCompact();
|
|
255
|
+
assert.equal(compacts, 1);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test('armCompact: a big session keeps the Mac awake until the timer fires, then lets it sleep', async () => {
|
|
259
|
+
const { armCompact, cancelCompact } = _internal;
|
|
260
|
+
const spawned = [], stopped = [];
|
|
261
|
+
let fire;
|
|
262
|
+
const $ = routingApi();
|
|
263
|
+
$.clock.after = (ms, fn) => { fire = fn; return { cancel() {} }; };
|
|
264
|
+
$.session.compact = async () => ({ tokensBefore: 174000, tokensAfter: 20000 });
|
|
265
|
+
$.session.usage = async () => ({ context: { tokens: 174000 }, rateLimits: [] });
|
|
266
|
+
$.process = { spawn: ({ argv }) => {
|
|
267
|
+
spawned.push(argv.join(' '));
|
|
268
|
+
return { [Symbol.asyncIterator]: () => ({ next: () => new Promise(() => {}), return: async () => { stopped.push(1); return { done: true }; } }) };
|
|
269
|
+
} };
|
|
270
|
+
armCompact($, 55 * 60 * 1000, { stayAwake: true });
|
|
271
|
+
assert.deepEqual(spawned, ['caffeinate -i -t 3420']); // 55 min + 2 min margin
|
|
272
|
+
fire();
|
|
273
|
+
await new Promise(r => setTimeout(r, 10));
|
|
274
|
+
assert.equal(stopped.length, 1); // compacted: sleep allowed again
|
|
275
|
+
armCompact($, 55 * 60 * 1000, { stayAwake: true });
|
|
276
|
+
cancelCompact(); // user typed first
|
|
277
|
+
assert.equal(stopped.length, 2);
|
|
278
|
+
armCompact($, 55 * 60 * 1000); // small session: never keeps awake
|
|
279
|
+
assert.equal(spawned.length, 2);
|
|
280
|
+
cancelCompact();
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test('askedFor: a resumed session that reopens on the routed model is mapped back to the model asked for', async () => {
|
|
284
|
+
const { askedFor, rememberAsked, asked } = _internal;
|
|
285
|
+
const db = new Map();
|
|
286
|
+
const $ = { store: { get: async k => db.get(k), set: async (k, v) => { db.set(k, v); } } };
|
|
287
|
+
const opus = 'claude-opus-5-5', haiku = 'claude-haiku-4-5-20251001', sonnet = 'claude-sonnet-5-5';
|
|
288
|
+
assert.equal(await askedFor($, 's1', opus, false), opus);
|
|
289
|
+
await rememberAsked($, 's1', opus, haiku); // asked Opus, ran Haiku
|
|
290
|
+
asked.clear(); // Claude Code restarts, --resume
|
|
291
|
+
assert.equal(await askedFor($, 's1', haiku, false), opus); // transcript says Haiku: still Opus asked
|
|
292
|
+
assert.equal(await askedFor($, 's1', sonnet, true), sonnet); // the user's own /model wins
|
|
293
|
+
assert.equal(await askedFor($, 's2', haiku, false), haiku); // never routed: Haiku is the pick
|
|
294
|
+
});
|