troxy-cli 1.26.0 → 1.28.0

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.26.0",
3
+ "version": "1.28.0",
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": {
@@ -42,6 +42,11 @@ export function extractUsage(entry) {
42
42
  // dollar math server-side from the model id; this file only sums what
43
43
  // the transcript says was used.
44
44
  tokens: inputTokens + outputTokens + cacheCreation + cacheRead,
45
+ // Lives on the transcript ENTRY itself, not inside message.usage -
46
+ // confirmed live 2026-09-03 against a real session file (values seen:
47
+ // 'high', 'max'). Whatever string Claude Code actually used, passed
48
+ // through as-is; nothing here assumes a fixed low/medium/high vocabulary.
49
+ effort: entry.effort || null,
45
50
  };
46
51
  }
47
52
 
@@ -94,14 +99,16 @@ export function usageForCurrentTurn(transcriptPath) {
94
99
  turnLines.push(entry);
95
100
  }
96
101
 
97
- const seen = new Map(); // messageId -> {model, tokens}
102
+ const seen = new Map(); // messageId -> {model, tokens, effort}
98
103
  let lastModel = null;
104
+ let lastEffort = null;
99
105
  for (const entry of turnLines) {
100
106
  const usage = extractUsage(entry);
101
107
  if (!usage || !usage.messageId) continue;
102
108
  if (!seen.has(usage.messageId)) {
103
109
  seen.set(usage.messageId, usage);
104
110
  lastModel = lastModel || usage.model;
111
+ lastEffort = lastEffort || usage.effort;
105
112
  }
106
113
  }
107
114
 
@@ -130,7 +137,7 @@ export function usageForCurrentTurn(transcriptPath) {
130
137
  }
131
138
  const contentExcerpt = replyText ? replyText.slice(0, MAX_EXCERPT_LEN) : null;
132
139
 
133
- return { tokens: totalTokens, model: lastModel, turnKey, contentExcerpt };
140
+ return { tokens: totalTokens, model: lastModel, effort: lastEffort, turnKey, contentExcerpt };
134
141
  }
135
142
 
136
143
  function withTimeout(promise, ms) {
package/src/mcp-server.js CHANGED
@@ -336,8 +336,7 @@ export async function runMcp() {
336
336
  },
337
337
  effort: {
338
338
  type: 'string',
339
- enum: ['low', 'medium', 'high'],
340
- description: 'How much work this task warrants. Report it honestly: "high effort on a simple task" is one of the things Troxy flags as waste.',
339
+ description: 'How much work this task warrants - your own real effort/thinking level for this turn (e.g. low, medium, high, or whatever your model calls it). Report it honestly: "high effort on a simple task" is one of the things Troxy flags as waste.',
341
340
  },
342
341
  task: {
343
342
  type: 'string',
@@ -379,7 +378,6 @@ export async function runMcp() {
379
378
  },
380
379
  effort: {
381
380
  type: 'string',
382
- enum: ['low', 'medium', 'high'],
383
381
  description: 'The effort level the task actually took, if it differed from what you declared up front (optional).',
384
382
  },
385
383
  },
@@ -578,7 +576,8 @@ export async function runMcp() {
578
576
  if (result.error) {
579
577
  return { content: [{ type: 'text', text: `Troxy error: ${result.error}` }], isError: true };
580
578
  }
581
- const { decision, reason, audit_id, approval_token, suggested_model, suggestion_reason } = result;
579
+ const { decision, reason, audit_id, approval_token, suggested_model, suggestion_reason,
580
+ suggested_effort, effort_suggestion_reason } = result;
582
581
  const what = `${args.model}${args.effort ? ` at ${args.effort} effort` : ''}`;
583
582
  // Every non-blocked branch repeats the audit_id and the instruction to
584
583
  // report back. Half the value of this checkpoint is the second call, and
@@ -590,11 +589,16 @@ export async function runMcp() {
590
589
  const suggestionText = suggested_model
591
590
  ? ` Suggestion: ${suggestion_reason || `${suggested_model} would likely work just as well here.`} If you'd like to use it, call evaluate_model again with model="${suggested_model}" — no obligation, this run is already approved as-is.`
592
591
  : '';
592
+ // Same pattern, same dark-ship gate, sibling advisory - independent of
593
+ // suggested_model so a call can carry either, both, or neither.
594
+ const effortSuggestionText = suggested_effort
595
+ ? ` Suggestion: ${effort_suggestion_reason || `${suggested_effort} effort would likely work just as well here.`} If you'd like to use it, call evaluate_model again with effort="${suggested_effort}" — no obligation, this run is already approved as-is.`
596
+ : '';
593
597
  let modelText;
594
598
  switch (decision) {
595
599
  case 'ALLOW':
596
600
  case 'NOTIFY':
597
- modelText = `✓ Approved: ${what}.${reason ? ` ${reason}` : ''} You may run it.${followUp}${suggestionText} (audit: ${audit_id})`;
601
+ modelText = `✓ Approved: ${what}.${reason ? ` ${reason}` : ''} You may run it.${followUp}${suggestionText}${effortSuggestionText} (audit: ${audit_id})`;
598
602
  break;
599
603
  case 'BLOCK':
600
604
  modelText = `✗ Blocked: ${what}.${reason ? ` ${reason}` : ''} Do not use this model for this task. Choose a cheaper or smaller model and call evaluate_model again with the new model id. (audit: ${audit_id})`;
@@ -91,8 +91,15 @@ export async function runPreToolUseHook() {
91
91
  const apiKey = process.env.TROXY_API_KEY || config?.apiKey;
92
92
  if (!apiKey) return allow();
93
93
 
94
+ const body = { model: usage.model, task: payload.tool_name || '' };
95
+ // Real effort straight off the transcript entry (see hook-report.js's
96
+ // extractUsage) - whatever Claude Code actually used, not coerced into a
97
+ // guessed low/medium/high. Omitted when the transcript doesn't carry one
98
+ // (an older Claude Code build, or a turn before any assistant reply yet).
99
+ if (usage.effort) body.effort = usage.effort;
100
+
94
101
  const result = await withTimeout(
95
- api.evaluateModel({ model: usage.model, task: payload.tool_name || '' }, apiKey),
102
+ api.evaluateModel(body, apiKey),
96
103
  GATE_TIMEOUT_MS,
97
104
  );
98
105
 
@@ -30,8 +30,10 @@ function writeLines(lines) {
30
30
  fs.writeFileSync(transcriptPath(), lines.map(l => JSON.stringify(l)).join('\n') + '\n');
31
31
  }
32
32
 
33
- function assistantLine(messageId, usage, model = 'claude-sonnet-5') {
34
- return { type: 'assistant', message: { id: messageId, model, usage } };
33
+ function assistantLine(messageId, usage, model = 'claude-sonnet-5', effort = undefined) {
34
+ const line = { type: 'assistant', message: { id: messageId, model, usage } };
35
+ if (effort !== undefined) line.effort = effort;
36
+ return line;
35
37
  }
36
38
 
37
39
  function assistantLineWithText(messageId, usage, text, model = 'claude-sonnet-5') {
@@ -41,9 +43,10 @@ function assistantLineWithText(messageId, usage, text, model = 'claude-sonnet-5'
41
43
  const USAGE_A = { input_tokens: 2, output_tokens: 1176, cache_creation_input_tokens: 4134, cache_read_input_tokens: 244103 };
42
44
 
43
45
  describe('extractUsage', () => {
44
- it('only reads usage/model/id fields, never message.content', () => {
46
+ it('only reads usage/model/id/effort fields, never message.content', () => {
45
47
  const entry = {
46
48
  type: 'assistant',
49
+ effort: 'high',
47
50
  message: {
48
51
  id: 'msg_1',
49
52
  model: 'claude-sonnet-5',
@@ -52,10 +55,19 @@ describe('extractUsage', () => {
52
55
  },
53
56
  };
54
57
  const result = extractUsage(entry);
55
- assert.deepEqual(Object.keys(result).sort(), ['messageId', 'model', 'tokens']);
58
+ assert.deepEqual(Object.keys(result).sort(), ['effort', 'messageId', 'model', 'tokens']);
59
+ assert.equal(result.effort, 'high');
56
60
  assert.ok(!JSON.stringify(result).includes('actual conversation'));
57
61
  });
58
62
 
63
+ it('effort is null when the entry does not carry one (older Claude Code build)', () => {
64
+ const entry = {
65
+ type: 'assistant',
66
+ message: { id: 'msg_1', model: 'claude-sonnet-5', usage: { input_tokens: 10, output_tokens: 5 } },
67
+ };
68
+ assert.equal(extractUsage(entry).effort, null);
69
+ });
70
+
59
71
  it('returns null for a non-assistant line', () => {
60
72
  assert.equal(extractUsage({ type: 'user', message: { usage: { input_tokens: 1 } } }), null);
61
73
  });
@@ -112,6 +124,24 @@ describe('usageForCurrentTurn', () => {
112
124
  assert.equal(result.model, 'claude-opus-4.5');
113
125
  });
114
126
 
127
+ it('returns the effort from the assistant lines in the current turn', () => {
128
+ writeLines([
129
+ { type: 'user', message: { content: [{ type: 'text', text: 'hi' }] } },
130
+ assistantLine('msg_1', { input_tokens: 1, output_tokens: 1 }, 'claude-opus-5', 'max'),
131
+ ]);
132
+ const result = usageForCurrentTurn(transcriptPath());
133
+ assert.equal(result.effort, 'max');
134
+ });
135
+
136
+ it('effort is null, not an error, on a transcript with no effort field (older Claude Code build)', () => {
137
+ writeLines([
138
+ { type: 'user', message: { content: [{ type: 'text', text: 'hi' }] } },
139
+ assistantLine('msg_1', { input_tokens: 1, output_tokens: 1 }),
140
+ ]);
141
+ const result = usageForCurrentTurn(transcriptPath());
142
+ assert.equal(result.effort, null);
143
+ });
144
+
115
145
  it('returns a stable turn_key for the same set of message ids, order-independent', () => {
116
146
  writeLines([
117
147
  { type: 'user', message: { content: [] } },