troxy-cli 1.27.0 → 1.29.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.27.0",
3
+ "version": "1.29.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,29 @@ 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;
105
+ // How many tool_use content blocks this turn has produced so far -
106
+ // Claude Code writes one JSONL line per content block (see the big
107
+ // comment above), so this is a real count, not a guess, of tool calls
108
+ // already made this turn, before whichever one just triggered this hook.
109
+ // Only the block's `type` tag is read here, a category label
110
+ // ("tool_use"/"thinking"/"text"), never its actual name or arguments -
111
+ // same narrow-purpose exception extractReplyText already sets for
112
+ // touching `content` at all, still never touching real conversation data.
113
+ let toolUseBlocksSoFar = 0;
99
114
  for (const entry of turnLines) {
100
115
  const usage = extractUsage(entry);
101
116
  if (!usage || !usage.messageId) continue;
102
117
  if (!seen.has(usage.messageId)) {
103
118
  seen.set(usage.messageId, usage);
104
119
  lastModel = lastModel || usage.model;
120
+ lastEffort = lastEffort || usage.effort;
121
+ }
122
+ const content = entry?.type === 'assistant' ? entry.message?.content : null;
123
+ if (Array.isArray(content) && content.some(b => b?.type === 'tool_use')) {
124
+ toolUseBlocksSoFar++;
105
125
  }
106
126
  }
107
127
 
@@ -130,7 +150,10 @@ export function usageForCurrentTurn(transcriptPath) {
130
150
  }
131
151
  const contentExcerpt = replyText ? replyText.slice(0, MAX_EXCERPT_LEN) : null;
132
152
 
133
- return { tokens: totalTokens, model: lastModel, turnKey, contentExcerpt };
153
+ return {
154
+ tokens: totalTokens, model: lastModel, effort: lastEffort, toolUseBlocksSoFar,
155
+ turnKey, contentExcerpt,
156
+ };
134
157
  }
135
158
 
136
159
  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
  },
@@ -91,8 +91,24 @@ 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
+ // How many tools this turn has used so far (from the transcript), plus
101
+ // the one about to run that triggered this hook. An undercount by
102
+ // nature - a PreToolUse hook cannot know how many MORE tool calls this
103
+ // turn will end up making - but a real, current-as-of-right-now number,
104
+ // not a guess, and the effort-suggestion feature only needs to tell a
105
+ // turn's first tool call apart from its fifth, not know the eventual
106
+ // total. Without this, effort was accepted but never actually
107
+ // classified as "simple", so no suggestion could ever fire from here.
108
+ body.estimated_tool_count = usage.toolUseBlocksSoFar + 1;
109
+
94
110
  const result = await withTimeout(
95
- api.evaluateModel({ model: usage.model, task: payload.tool_name || '' }, apiKey),
111
+ api.evaluateModel(body, apiKey),
96
112
  GATE_TIMEOUT_MS,
97
113
  );
98
114
 
@@ -30,20 +30,27 @@ 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') {
38
40
  return { type: 'assistant', message: { id: messageId, model, usage, content: [{ type: 'text', text }] } };
39
41
  }
40
42
 
43
+ function assistantToolUseLine(messageId, usage, toolName = 'Bash', model = 'claude-sonnet-5') {
44
+ return { type: 'assistant', message: { id: messageId, model, usage, content: [{ type: 'tool_use', name: toolName }] } };
45
+ }
46
+
41
47
  const USAGE_A = { input_tokens: 2, output_tokens: 1176, cache_creation_input_tokens: 4134, cache_read_input_tokens: 244103 };
42
48
 
43
49
  describe('extractUsage', () => {
44
- it('only reads usage/model/id fields, never message.content', () => {
50
+ it('only reads usage/model/id/effort fields, never message.content', () => {
45
51
  const entry = {
46
52
  type: 'assistant',
53
+ effort: 'high',
47
54
  message: {
48
55
  id: 'msg_1',
49
56
  model: 'claude-sonnet-5',
@@ -52,10 +59,19 @@ describe('extractUsage', () => {
52
59
  },
53
60
  };
54
61
  const result = extractUsage(entry);
55
- assert.deepEqual(Object.keys(result).sort(), ['messageId', 'model', 'tokens']);
62
+ assert.deepEqual(Object.keys(result).sort(), ['effort', 'messageId', 'model', 'tokens']);
63
+ assert.equal(result.effort, 'high');
56
64
  assert.ok(!JSON.stringify(result).includes('actual conversation'));
57
65
  });
58
66
 
67
+ it('effort is null when the entry does not carry one (older Claude Code build)', () => {
68
+ const entry = {
69
+ type: 'assistant',
70
+ message: { id: 'msg_1', model: 'claude-sonnet-5', usage: { input_tokens: 10, output_tokens: 5 } },
71
+ };
72
+ assert.equal(extractUsage(entry).effort, null);
73
+ });
74
+
59
75
  it('returns null for a non-assistant line', () => {
60
76
  assert.equal(extractUsage({ type: 'user', message: { usage: { input_tokens: 1 } } }), null);
61
77
  });
@@ -112,6 +128,47 @@ describe('usageForCurrentTurn', () => {
112
128
  assert.equal(result.model, 'claude-opus-4.5');
113
129
  });
114
130
 
131
+ it('returns the effort from the assistant lines in the current turn', () => {
132
+ writeLines([
133
+ { type: 'user', message: { content: [{ type: 'text', text: 'hi' }] } },
134
+ assistantLine('msg_1', { input_tokens: 1, output_tokens: 1 }, 'claude-opus-5', 'max'),
135
+ ]);
136
+ const result = usageForCurrentTurn(transcriptPath());
137
+ assert.equal(result.effort, 'max');
138
+ });
139
+
140
+ it('effort is null, not an error, on a transcript with no effort field (older Claude Code build)', () => {
141
+ writeLines([
142
+ { type: 'user', message: { content: [{ type: 'text', text: 'hi' }] } },
143
+ assistantLine('msg_1', { input_tokens: 1, output_tokens: 1 }),
144
+ ]);
145
+ const result = usageForCurrentTurn(transcriptPath());
146
+ assert.equal(result.effort, null);
147
+ });
148
+
149
+ it('counts tool_use content blocks in the current turn, not text/thinking ones', () => {
150
+ writeLines([
151
+ { type: 'user', message: { content: [{ type: 'text', text: 'hi' }] } },
152
+ // Same real shape: one JSONL line per content block, all three lines
153
+ // share message.id 'msg_1' and repeat the same usage object.
154
+ { type: 'assistant', message: { id: 'msg_1', model: 'claude-sonnet-5', usage: { input_tokens: 1, output_tokens: 1 }, content: [{ type: 'thinking', thinking: 'planning' }] } },
155
+ assistantToolUseLine('msg_1', { input_tokens: 1, output_tokens: 1 }, 'Read'),
156
+ assistantToolUseLine('msg_2', { input_tokens: 1, output_tokens: 1 }, 'Bash'),
157
+ assistantLineWithText('msg_3', { input_tokens: 1, output_tokens: 1 }, 'done'),
158
+ ]);
159
+ const result = usageForCurrentTurn(transcriptPath());
160
+ assert.equal(result.toolUseBlocksSoFar, 2);
161
+ });
162
+
163
+ it('toolUseBlocksSoFar is 0 for a turn with no tool use yet', () => {
164
+ writeLines([
165
+ { type: 'user', message: { content: [{ type: 'text', text: 'hi' }] } },
166
+ assistantLineWithText('msg_1', { input_tokens: 1, output_tokens: 1 }, 'just chatting'),
167
+ ]);
168
+ const result = usageForCurrentTurn(transcriptPath());
169
+ assert.equal(result.toolUseBlocksSoFar, 0);
170
+ });
171
+
115
172
  it('returns a stable turn_key for the same set of message ids, order-independent', () => {
116
173
  writeLines([
117
174
  { type: 'user', message: { content: [] } },