troxy-cli 1.29.63 → 1.29.64

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.63",
3
+ "version": "1.29.64",
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": {
@@ -369,7 +369,7 @@ async function scorePrompt($, e, next) {
369
369
  const now = await $.clock.now();
370
370
  const { value: shown } = await $.state.get(COMPACTED);
371
371
  if (shown && !(now - shown.at < COMPACTED_SHOW_MS)) await $.state.set(COMPACTED, null);
372
- const ls = lastSent.get(sid);
372
+ const ls = await lastSentFor($, sid);
373
373
  let idleSince = ls ? ls.at : null;
374
374
  try {
375
375
  const stored = await $.store.get(`idle_started_${sid}`);
@@ -398,6 +398,18 @@ async function scorePrompt($, e, next) {
398
398
  const lastAsked = new Map();
399
399
  // The model each session last ran on and when: its cache is warm for about an hour.
400
400
  const lastSent = new Map();
401
+ // Kept on disk too: each `claude -p` step and every resume or restart is a new process,
402
+ // and an in-memory-only map made those look cold, so the mod re-picked a model while the
403
+ // cache was still warm (the 2026-10-15/16 A/Bs re-routed at almost every step).
404
+ // ponytail: one small key per session, never pruned; prune with asked: keys if it grows.
405
+ async function lastSentFor($, sid) {
406
+ if (!lastSent.has(sid)) {
407
+ let v = null;
408
+ try { v = (await $.store.get(`sent:${sid}`)) || null; } catch {}
409
+ lastSent.set(sid, v);
410
+ }
411
+ return lastSent.get(sid);
412
+ }
401
413
  const WARM_MS = 55 * 60 * 1000;
402
414
  // Sessions whose next step is a cold moment because the conversation was just compacted.
403
415
  const coldNext = new Set();
@@ -445,7 +457,7 @@ async function* routeStep($, e, next) {
445
457
  lastAsked.set(sid, e.model);
446
458
  const switched = Boolean(prev && prev !== e.model);
447
459
  const want = await askedFor($, sid, e.model, switched);
448
- const ls = lastSent.get(sid);
460
+ const ls = await lastSentFor($, sid);
449
461
  const isCold = !ls || now - ls.at >= WARM_MS || coldNext.has(sid);
450
462
  const model = (await $.store.get('paused')) ? want : stepModel({
451
463
  arm, requested: want, switched,
@@ -454,6 +466,7 @@ async function* routeStep($, e, next) {
454
466
  });
455
467
  coldNext.delete(sid);
456
468
  lastSent.set(sid, { model, at: now });
469
+ try { await $.store.set(`sent:${sid}`, { model, at: now }); } catch {} // a missed write only makes the next process cold
457
470
  // The daemon tags every call with the arm, plus from/to when the model changed.
458
471
  const file = modRouteFile($.plugin.root, sid);
459
472
  const key = `${sid}:${arm}:${want}>${model}`;
@@ -538,4 +551,4 @@ export function register(on) {
538
551
  }
539
552
 
540
553
  // Exported for tests only - not part of the mod's runtime contract.
541
- export const _internal = { 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 };
554
+ export const _internal = { 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 };
@@ -188,6 +188,22 @@ test('routeStep: a /model switch wins and becomes the held model', async () => {
188
188
  assert.equal(await runTurn($, sonnet), sonnet); // warm on sonnet: held, no switch
189
189
  });
190
190
 
191
+ test('routeStep: a new process (claude -p step, resume, restart) keeps the warm model from disk', async () => {
192
+ const { MODELS: M, lastSent } = _internal;
193
+ const disk = new Map();
194
+ let now = 0, score = '0.1';
195
+ const $ = { ...routingApi(),
196
+ store: { get: async (k) => disk.get(k), set: async (k, v) => disk.set(k, v) },
197
+ clock: { now: async () => now },
198
+ model: { complete: async () => ({ isAnswered: true, text: score }) } };
199
+ assert.equal(await runTurn($, M.opus), M.sonnet); // cold start, easy prompt: sonnet
200
+ lastSent.clear(); score = '0.95'; // new process, hard prompt
201
+ now += 10 * 60 * 1000;
202
+ assert.equal(await runTurn($, M.opus), M.sonnet); // cache still warm: held
203
+ lastSent.clear(); now += 56 * 60 * 1000;
204
+ assert.equal(await runTurn($, M.opus), M.opus); // 55+ min idle: cold, re-picked
205
+ });
206
+
191
207
  test('stepModel: decide only at cold moments, hold in between, holdout never routes', () => {
192
208
  const { stepModel, MODELS: M } = _internal;
193
209
  const base = { arm: 'treated', requested: M.opus, switched: false, warm: M.haiku };