troxy-cli 1.29.49 → 1.29.50

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.49",
3
+ "version": "1.29.50",
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,54 @@ 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
- function armCompact($, ms) {
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 } = {}) {
278
284
  cancelCompact();
279
- compactTimer = $.clock.after(ms, () => { compactTimer = null; idleCompact($).catch(() => {}); });
285
+ if (stayAwake) keepAwake($, ms + 2 * 60 * 1000);
286
+ compactTimer = $.clock.after(ms, () => {
287
+ compactTimer = null;
288
+ idleCompact($).catch(() => {}).finally(stopAwake);
289
+ });
290
+ }
291
+
292
+ // ponytail: -t caps it, so if return() cannot kill the child it still ends on its own.
293
+ function keepAwake($, ms) {
294
+ stopAwake();
295
+ try {
296
+ const it = $.process.spawn({ argv: ['caffeinate', '-i', '-t', String(Math.ceil(ms / 1000))] })[Symbol.asyncIterator]();
297
+ awake = it;
298
+ it.next().catch(() => {}).finally(() => { if (awake === it) awake = null; });
299
+ } catch {}
300
+ }
301
+
302
+ function stopAwake() {
303
+ const it = awake;
304
+ awake = null;
305
+ if (it && it.return) Promise.resolve(it.return()).catch(() => {});
306
+ }
307
+
308
+ async function bigSession($) {
309
+ if (await $.store.get('compactOff')) return false;
310
+ const u = await $.session.usage();
311
+ return ((u.context && u.context.tokens) || 0) >= COMPACT_MIN_TOKENS;
280
312
  }
281
313
 
282
- async function idleCompact($) {
314
+ // late: the user is back after a long idle (the Mac may have slept through the timer),
315
+ // so compact now or not at all: never hold their prompt waiting for a meter reset.
316
+ async function idleCompact($, { late = false } = {}) {
283
317
  const u = await $.session.usage();
284
318
  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);
319
+ if (plan.action === 'wait' && !late) return armCompact($, plan.inMs);
286
320
  if (plan.action !== 'compact') return;
287
321
  const r = await $.session.compact({ instructions: COMPACT_INSTRUCTIONS });
288
322
  if (r.skip) return;
@@ -311,8 +345,14 @@ async function scorePrompt($, e, next) {
311
345
  decision = null;
312
346
  cancelCompact();
313
347
  if ((await $.state.get(COMPACTED)).value) await $.state.set(COMPACTED, null);
314
- turn = { start: await snapshot($), ratio: 1 };
315
348
  const sid = await $.session.id();
349
+ // Idle past the timer by the wall clock: a sleeping Mac freezes the timer, so the
350
+ // compaction it missed runs here, before this prompt goes out on the big session.
351
+ const ls = lastSent.get(sid);
352
+ if (ls && (await $.clock.now()) - ls.at >= COMPACT_IDLE_MS) {
353
+ try { await idleCompact($, { late: true }); } catch {} // fail open: send the prompt as-is
354
+ }
355
+ turn = { start: await snapshot($), ratio: 1 };
316
356
  const file = modRouteFile($.plugin.root, sid);
317
357
  if (file && lastNote !== `${sid}:{}`) { lastNote = `${sid}:{}`; await $.fs.write(file, '{}'); } // clear the previous prompt's note
318
358
  if (await $.store.get('paused')) return next(e);
@@ -339,6 +379,23 @@ const coldNext = new Set();
339
379
  // doesn't route mod sessions either), so treated vs holdout is a clean A/B.
340
380
  const ARM = { plugin: 'troxy-guard', key: 'arm' };
341
381
  const MOD_HOLDOUT_FRACTION = 0.1;
382
+ // The model the user asked for and the one that ran, per session, kept across restarts.
383
+ // The transcript records only the model that ran, so `claude --resume` reopens a routed
384
+ // session on Haiku; without this the mod would take Haiku as the user's pick for good.
385
+ // ponytail: a /model to the same model already showing is invisible, so it stays mapped.
386
+ const asked = new Map();
387
+ async function askedFor($, sid, model, switched) {
388
+ if (switched) asked.set(sid, null);
389
+ if (!asked.has(sid)) asked.set(sid, (await $.store.get(`asked:${sid}`)) || null);
390
+ const r = asked.get(sid);
391
+ return r && r.ran === model && r.asked !== model ? r.asked : model;
392
+ }
393
+ async function rememberAsked($, sid, want, ran) {
394
+ const r = asked.get(sid);
395
+ if (r && r.asked === want && r.ran === ran) return;
396
+ asked.set(sid, { asked: want, ran });
397
+ await $.store.set(`asked:${sid}`, { asked: want, ran });
398
+ }
342
399
 
343
400
  // Which model a step runs on. Decide only at a cold moment (first step, 55+ min
344
401
  // idle, after a compaction): the cache is cold anyway, so any model is free to
@@ -360,9 +417,11 @@ async function* routeStep($, e, next) {
360
417
  if (!arm) { arm = Math.random() < MOD_HOLDOUT_FRACTION ? 'holdout' : 'treated'; await $.state.set(ARM, arm); }
361
418
  const prev = lastAsked.get(sid);
362
419
  lastAsked.set(sid, e.model);
420
+ const switched = Boolean(prev && prev !== e.model);
421
+ const want = await askedFor($, sid, e.model, switched);
363
422
  const ls = lastSent.get(sid);
364
- const model = (await $.store.get('paused')) ? e.model : stepModel({
365
- arm, requested: e.model, switched: Boolean(prev && prev !== e.model),
423
+ const model = (await $.store.get('paused')) ? want : stepModel({
424
+ arm, requested: want, switched,
366
425
  cold: !ls || now - ls.at >= WARM_MS || coldNext.has(sid),
367
426
  score: decision ? decision.score : null, warm: ls && ls.model,
368
427
  });
@@ -370,12 +429,13 @@ async function* routeStep($, e, next) {
370
429
  lastSent.set(sid, { model, at: now });
371
430
  // The daemon tags every call with the arm, plus from/to when the model changed.
372
431
  const file = modRouteFile($.plugin.root, sid);
373
- const key = `${sid}:${arm}:${e.model}>${model}`;
432
+ const key = `${sid}:${arm}:${want}>${model}`;
374
433
  if (file && lastNote !== key) {
375
434
  lastNote = key;
376
- await $.fs.write(file, JSON.stringify(model === e.model ? { arm, ts: now } : { arm, from: e.model, to: model, ts: now }));
435
+ await $.fs.write(file, JSON.stringify(model === want ? { arm, ts: now } : { arm, from: want, to: model, ts: now }));
377
436
  }
378
- if (model !== e.model && turn) turn.ratio = PRICE[tierOf(e.model)] / PRICE[tierOf(model)];
437
+ try { await rememberAsked($, sid, want, model); } catch {} // a missed write only loses this on resume
438
+ if (model !== want && turn) turn.ratio = PRICE[tierOf(want)] / PRICE[tierOf(model)];
379
439
  return yield* next(model === e.model ? e : { ...e, model });
380
440
  }
381
441
 
@@ -440,10 +500,12 @@ export function register(on) {
440
500
  const text = savingsText(await fetchSavings($));
441
501
  if (text) await $.state.set(SAVINGS, text);
442
502
  } catch {} // a missed fetch only leaves the band as it was
443
- armCompact($, COMPACT_IDLE_MS);
503
+ let stayAwake = false;
504
+ try { stayAwake = await bigSession($); } catch {}
505
+ armCompact($, COMPACT_IDLE_MS, { stayAwake });
444
506
  return r;
445
507
  });
446
508
  }
447
509
 
448
510
  // 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 };
511
+ 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,60 @@ 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('armCompact: a big session keeps the Mac awake until the timer fires, then lets it sleep', async () => {
237
+ const { armCompact, cancelCompact } = _internal;
238
+ const spawned = [], stopped = [];
239
+ let fire;
240
+ const $ = routingApi();
241
+ $.clock.after = (ms, fn) => { fire = fn; return { cancel() {} }; };
242
+ $.session.compact = async () => ({ tokensBefore: 174000, tokensAfter: 20000 });
243
+ $.session.usage = async () => ({ context: { tokens: 174000 }, rateLimits: [] });
244
+ $.process = { spawn: ({ argv }) => {
245
+ spawned.push(argv.join(' '));
246
+ return { [Symbol.asyncIterator]: () => ({ next: () => new Promise(() => {}), return: async () => { stopped.push(1); return { done: true }; } }) };
247
+ } };
248
+ armCompact($, 55 * 60 * 1000, { stayAwake: true });
249
+ assert.deepEqual(spawned, ['caffeinate -i -t 3420']); // 55 min + 2 min margin
250
+ fire();
251
+ await new Promise(r => setTimeout(r, 10));
252
+ assert.equal(stopped.length, 1); // compacted: sleep allowed again
253
+ armCompact($, 55 * 60 * 1000, { stayAwake: true });
254
+ cancelCompact(); // user typed first
255
+ assert.equal(stopped.length, 2);
256
+ armCompact($, 55 * 60 * 1000); // small session: never keeps awake
257
+ assert.equal(spawned.length, 2);
258
+ cancelCompact();
259
+ });
260
+
261
+ test('askedFor: a resumed session that reopens on the routed model is mapped back to the model asked for', async () => {
262
+ const { askedFor, rememberAsked, asked } = _internal;
263
+ const db = new Map();
264
+ const $ = { store: { get: async k => db.get(k), set: async (k, v) => { db.set(k, v); } } };
265
+ const opus = 'claude-opus-5-5', haiku = 'claude-haiku-4-5-20251001', sonnet = 'claude-sonnet-5-5';
266
+ assert.equal(await askedFor($, 's1', opus, false), opus);
267
+ await rememberAsked($, 's1', opus, haiku); // asked Opus, ran Haiku
268
+ asked.clear(); // Claude Code restarts, --resume
269
+ assert.equal(await askedFor($, 's1', haiku, false), opus); // transcript says Haiku: still Opus asked
270
+ assert.equal(await askedFor($, 's1', sonnet, true), sonnet); // the user's own /model wins
271
+ assert.equal(await askedFor($, 's2', haiku, false), haiku); // never routed: Haiku is the pick
272
+ });