troxy-cli 1.29.50 → 1.29.52

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.50",
3
+ "version": "1.29.52",
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": {
@@ -118,9 +118,9 @@ function _readModRoute(dir, sessionId) {
118
118
  const arm = n.arm === 'treated' || n.arm === 'holdout' ? n.arm : null;
119
119
  const isFresh = Date.now() - n.ts < _MOD_ROUTE_MAX_AGE_MS;
120
120
  const routed = isFresh && typeof n.from === 'string' && typeof n.to === 'string';
121
- return { arm, from: routed ? n.from : null, to: routed ? n.to : null };
121
+ return { arm, from: routed ? n.from : null, to: routed ? n.to : null, compact: isFresh && n.compact === true };
122
122
  } catch {
123
- return { arm: null, from: null, to: null };
123
+ return { arm: null, from: null, to: null, compact: false };
124
124
  }
125
125
  }
126
126
 
@@ -181,6 +181,8 @@ export function buildTroxyForwardHeaders(incomingHeaders, troxyKey, sandboxState
181
181
  }
182
182
  // The mod is this session's one router: the proxy must not route it, and logs the arm.
183
183
  if (modRoute && modRoute.arm) headers['x-troxy-mod-arm'] = modRoute.arm;
184
+ // The mod's idle compaction is in flight: this is its summary request.
185
+ if (modRoute && modRoute.compact) headers['x-troxy-mod-compact'] = '1';
184
186
  headers.authorization = `Bearer ${troxyKey}`;
185
187
  return headers;
186
188
  }
@@ -256,7 +256,8 @@ const COMPACT_MIN_TOKENS = 100000;
256
256
  const COMPACT_LIMIT_PCT = 95;
257
257
  const COMPACT_AFTER_RESET_MS = 60 * 1000;
258
258
  const COMPACT_INSTRUCTIONS = 'Keep the current task, the files involved, decisions made, and open to-dos.';
259
- const COMPACTED = { plugin: 'troxy-guard', key: 'compacted' }; // the band's note until the next prompt
259
+ const COMPACTED = { plugin: 'troxy-guard', key: 'compacted' }; // the band's note: { text, at }
260
+ const COMPACTED_SHOW_MS = 10 * 60 * 1000; // kept through prompts for this long, so it isn't missed
260
261
  let compactTimer = null;
261
262
 
262
263
  // What to do when the idle timer fires: skip, compact now, or wait for the meter to reset.
@@ -280,9 +281,14 @@ function cancelCompact() {
280
281
  // stayAwake: hold off system sleep (the screen still sleeps) until the timer fires, so a
281
282
  // big session compacts on time instead of when the user is back. macOS caffeinate; where
282
283
  // it is missing the spawn fails quietly and compact-on-return in scorePrompt covers it.
283
- function armCompact($, ms, { stayAwake = false } = {}) {
284
+ function armCompact($, ms, { stayAwake = false, persist = false } = {}) {
284
285
  cancelCompact();
285
286
  if (stayAwake) keepAwake($, ms + 2 * 60 * 1000);
287
+ // Persisted so a restart (which loses lastSent and the timer) still finds the idle start.
288
+ if (persist) try {
289
+ Promise.all([$.session.id(), $.clock.now()])
290
+ .then(([sid, at]) => $.store.set(`idle_started_${sid}`, at)).catch(() => {});
291
+ } catch {}
286
292
  compactTimer = $.clock.after(ms, () => {
287
293
  compactTimer = null;
288
294
  idleCompact($).catch(() => {}).finally(stopAwake);
@@ -318,11 +324,16 @@ async function idleCompact($, { late = false } = {}) {
318
324
  const plan = compactPlan({ tokens: u.context && u.context.tokens, rateLimits: u.rateLimits, now: await $.clock.now(), off: await $.store.get('compactOff') });
319
325
  if (plan.action === 'wait' && !late) return armCompact($, plan.inMs);
320
326
  if (plan.action !== 'compact') return;
321
- const r = await $.session.compact({ instructions: COMPACT_INSTRUCTIONS });
327
+ // Tags the summary request so the proxy logs it as Troxy's compaction (the Activity row).
328
+ const file = modRouteFile($.plugin.root, await $.session.id());
329
+ if (file) { lastNote = null; await $.fs.write(file, JSON.stringify({ compact: true, ts: await $.clock.now() })); }
330
+ let r;
331
+ try { r = await $.session.compact({ instructions: COMPACT_INSTRUCTIONS }); }
332
+ finally { if (file) await $.fs.write(file, '{}'); }
322
333
  if (r.skip) return;
323
334
  const k = n => `${Math.round(n / 1000)}k`;
324
- await $.state.set(COMPACTED, r.tokensBefore && r.tokensAfter
325
- ? `compacted this idle session ${k(r.tokensBefore)} → ${k(r.tokensAfter)} tokens` : 'compacted this idle session');
335
+ await $.state.set(COMPACTED, { at: await $.clock.now(), text: r.tokensBefore && r.tokensAfter
336
+ ? `compacted this idle session ${k(r.tokensBefore)} → ${k(r.tokensAfter)} tokens` : 'compacted this idle session' });
326
337
  }
327
338
 
328
339
  // Where the local daemon looks for this session's routing note (see interceptor.js).
@@ -344,12 +355,19 @@ let decision = null;
344
355
  async function scorePrompt($, e, next) {
345
356
  decision = null;
346
357
  cancelCompact();
347
- if ((await $.state.get(COMPACTED)).value) await $.state.set(COMPACTED, null);
348
358
  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.
359
+ // Idle past the timer by the wall clock: a sleeping Mac freezes the timer and a restart
360
+ // loses it, so the compaction it missed runs here, before this prompt goes out.
361
+ const now = await $.clock.now();
362
+ const { value: shown } = await $.state.get(COMPACTED);
363
+ if (shown && !(now - shown.at < COMPACTED_SHOW_MS)) await $.state.set(COMPACTED, null);
351
364
  const ls = lastSent.get(sid);
352
- if (ls && (await $.clock.now()) - ls.at >= COMPACT_IDLE_MS) {
365
+ let idleSince = ls ? ls.at : null;
366
+ try {
367
+ const stored = await $.store.get(`idle_started_${sid}`);
368
+ if (typeof stored === 'number' && (idleSince == null || stored > idleSince)) idleSince = stored;
369
+ } catch {}
370
+ if (idleSince != null && now - idleSince >= COMPACT_IDLE_MS) {
353
371
  try { await idleCompact($, { late: true }); } catch {} // fail open: send the prompt as-is
354
372
  }
355
373
  turn = { start: await snapshot($), ratio: 1 };
@@ -397,13 +415,25 @@ async function rememberAsked($, sid, want, ran) {
397
415
  await $.store.set(`asked:${sid}`, { asked: want, ran });
398
416
  }
399
417
 
400
- // Which model a step runs on. Decide only at a cold moment (first step, 55+ min
401
- // idle, after a compaction): the cache is cold anyway, so any model is free to
402
- // pick, at any context size. In between, hold the warm model: no switch, no
403
- // cache rewrite. The user's own /model always wins and becomes the held model.
404
- function stepModel({ arm, requested, switched, cold, score, warm }) {
418
+ // Warm switches: a new model re-writes the cache once, then every request on it is
419
+ // cheaper, so a switch pays back in about one or two typed prompts if the model then
420
+ // holds. Switch at most once per HOLD_PROMPTS prompts, and only while the re-write is
421
+ // small enough to pay back.
422
+ const HOLD_PROMPTS = 2;
423
+ const WARM_SWITCH_MAX_TOKENS = 200000;
424
+ // Per session: the last prompt's decision and how many prompts ran on the current model.
425
+ const held = new Map();
426
+
427
+ // Which model a step runs on. At a cold moment (first step, 55+ min idle, after a
428
+ // compaction) any model is free to pick. While warm, hold the model, except at the
429
+ // first step of a new prompt once it has held HOLD_PROMPTS prompts: then re-pick by
430
+ // score, down or up. The user's own /model always wins and becomes the held model.
431
+ function stepModel({ arm, requested, switched, cold, score, warm, newPrompt, heldPrompts, tokens }) {
405
432
  if (arm !== 'treated' || switched) return requested;
406
433
  if (cold) return pickModel(score, requested, 0, null);
434
+ if (newPrompt && score !== null && heldPrompts >= HOLD_PROMPTS && tokens <= WARM_SWITCH_MAX_TOKENS) {
435
+ return pickModel(score, requested, 0, null);
436
+ }
407
437
  const w = tierOf(warm), rq = tierOf(requested);
408
438
  return w !== null && rq !== null && RANK[w] <= RANK[rq] ? warm : requested;
409
439
  }
@@ -420,11 +450,19 @@ async function* routeStep($, e, next) {
420
450
  const switched = Boolean(prev && prev !== e.model);
421
451
  const want = await askedFor($, sid, e.model, switched);
422
452
  const ls = lastSent.get(sid);
453
+ const h = held.get(sid) || { decision: null, prompts: 0 };
454
+ const newPrompt = Boolean(decision) && decision !== h.decision;
455
+ if (newPrompt) h.decision = decision;
423
456
  const model = (await $.store.get('paused')) ? want : stepModel({
424
457
  arm, requested: want, switched,
425
458
  cold: !ls || now - ls.at >= WARM_MS || coldNext.has(sid),
426
459
  score: decision ? decision.score : null, warm: ls && ls.model,
460
+ newPrompt, heldPrompts: h.prompts, tokens: decision ? decision.tokens : Infinity,
427
461
  });
462
+ // Count prompts on the current model; a change starts the count over.
463
+ if (!ls || model !== ls.model) h.prompts = newPrompt ? 1 : 0;
464
+ else if (newPrompt) h.prompts++;
465
+ held.set(sid, h);
428
466
  coldNext.delete(sid);
429
467
  lastSent.set(sid, { model, at: now });
430
468
  // The daemon tags every call with the arm, plus from/to when the model changed.
@@ -480,7 +518,7 @@ export function register(on) {
480
518
  const { value: kept } = await $.state.get(SAVINGS);
481
519
  if ((!line && !kept && !note) || e.props.hasSurvey) return next(e);
482
520
  const { Text } = $.ui.resolve(e);
483
- return h(Text, { color: TROXY_TEAL }, `Troxy ${[line, kept, note].filter(Boolean).join(' · ')}`);
521
+ return h(Text, { color: TROXY_TEAL }, `Troxy ${[line, kept, note && note.text].filter(Boolean).join(' · ')}`);
484
522
  });
485
523
  // After the answer, so counting never delays a prompt.
486
524
  on('turn.complete', async ($, e, next) => {
@@ -502,10 +540,10 @@ export function register(on) {
502
540
  } catch {} // a missed fetch only leaves the band as it was
503
541
  let stayAwake = false;
504
542
  try { stayAwake = await bigSession($); } catch {}
505
- armCompact($, COMPACT_IDLE_MS, { stayAwake });
543
+ armCompact($, COMPACT_IDLE_MS, { stayAwake, persist: true });
506
544
  return r;
507
545
  });
508
546
  }
509
547
 
510
548
  // Exported for tests only - not part of the mod's runtime contract.
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 };
549
+ 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, HOLD_PROMPTS };
@@ -163,6 +163,16 @@ describe('buildTroxyForwardHeaders', () => {
163
163
  note(dir, { arm: 'bogus', ts: Date.now() });
164
164
  assert.equal(build(dir)['x-troxy-mod-arm'], undefined);
165
165
  });
166
+
167
+ it('tags the idle-compaction summary request only while the note is fresh', () => {
168
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-modroute-'));
169
+ note(dir, { compact: true, ts: Date.now() });
170
+ assert.equal(build(dir)['x-troxy-mod-compact'], '1');
171
+ note(dir, { compact: true, ts: Date.now() - 31 * 60 * 1000 });
172
+ assert.equal(build(dir)['x-troxy-mod-compact'], undefined);
173
+ note(dir, {});
174
+ assert.equal(build(dir)['x-troxy-mod-compact'], undefined);
175
+ });
166
176
  });
167
177
 
168
178
  it('omits x-troxy-tool-sandbox header when no state file exists', () => {
@@ -189,6 +189,18 @@ test('stepModel: decide only at cold moments, hold in between, holdout never rou
189
189
  assert.equal(stepModel({ ...base, switched: true, cold: false, score: 0.1 }), M.opus);
190
190
  });
191
191
 
192
+ test('stepModel: warm switch at a new prompt only after the hold, and under the size cap', () => {
193
+ const { stepModel, MODELS: M, HOLD_PROMPTS } = _internal;
194
+ const base = { arm: 'treated', requested: M.opus, switched: false, warm: M.opus, cold: false, score: 0.1, tokens: 80000 };
195
+ assert.equal(stepModel({ ...base, newPrompt: true, heldPrompts: HOLD_PROMPTS }), M.haiku); // held long enough: switch down
196
+ assert.equal(stepModel({ ...base, newPrompt: true, heldPrompts: HOLD_PROMPTS - 1 }), M.opus); // still paying back: hold
197
+ assert.equal(stepModel({ ...base, newPrompt: false, heldPrompts: HOLD_PROMPTS }), M.opus); // mid-prompt tool step: hold
198
+ assert.equal(stepModel({ ...base, newPrompt: true, heldPrompts: HOLD_PROMPTS, tokens: 250000 }), M.opus); // re-write too big
199
+ assert.equal(stepModel({ ...base, newPrompt: true, heldPrompts: HOLD_PROMPTS, score: null }), M.opus); // no score: hold
200
+ assert.equal(stepModel({ ...base, warm: M.haiku, score: 0.9, newPrompt: true, heldPrompts: HOLD_PROMPTS }), M.opus); // hard prompt: back up
201
+ assert.equal(stepModel({ ...base, arm: 'holdout', newPrompt: true, heldPrompts: HOLD_PROMPTS }), M.opus);
202
+ });
203
+
192
204
  test('pickModel: past the floor, stay on the warm model unless the prompt needs a stronger one', () => {
193
205
  const { pickModel, MODELS } = _internal;
194
206
  const big = 80000;
@@ -233,6 +245,28 @@ test('scorePrompt: back after 55+ min by the wall clock (Mac slept through the t
233
245
  assert.ok(await runTurn($, opus)); // a failed compaction never blocks the prompt
234
246
  });
235
247
 
248
+ test('scorePrompt: idle 55+ min across a Claude Code restart still compacts, from the saved idle start', async () => {
249
+ const { armCompact, cancelCompact } = _internal;
250
+ const $ = routingApi();
251
+ const db = new Map();
252
+ let now = 1000, compacts = 0;
253
+ $.store = { get: async k => db.get(k), set: async (k, v) => { db.set(k, v); } };
254
+ $.clock.now = async () => now;
255
+ $.clock.after = () => ({ cancel() {} });
256
+ $.session.usage = async () => ({ context: { tokens: 174000 }, rateLimits: [] });
257
+ $.session.compact = async () => { compacts++; return { tokensBefore: 174000, tokensAfter: 20000 }; };
258
+ armCompact($, 55 * 60 * 1000, { persist: true }); // end of the last turn before the restart
259
+ await new Promise(r => setImmediate(r));
260
+ assert.equal(typeof [...db.values()][0], 'number');
261
+ now += 40 * 60 * 1000;
262
+ armCompact($, 55 * 60 * 1000); // session.start after the restart: must not reset it
263
+ await new Promise(r => setImmediate(r));
264
+ now += 16 * 60 * 1000;
265
+ await runTurn($, _internal.MODELS.opus); // fresh process: no lastSent, only the store
266
+ cancelCompact();
267
+ assert.equal(compacts, 1);
268
+ });
269
+
236
270
  test('armCompact: a big session keeps the Mac awake until the timer fires, then lets it sleep', async () => {
237
271
  const { armCompact, cancelCompact } = _internal;
238
272
  const spawned = [], stopped = [];