troxy-cli 1.28.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.28.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": {
@@ -102,6 +102,15 @@ export function usageForCurrentTurn(transcriptPath) {
102
102
  const seen = new Map(); // messageId -> {model, tokens, effort}
103
103
  let lastModel = null;
104
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;
105
114
  for (const entry of turnLines) {
106
115
  const usage = extractUsage(entry);
107
116
  if (!usage || !usage.messageId) continue;
@@ -110,6 +119,10 @@ export function usageForCurrentTurn(transcriptPath) {
110
119
  lastModel = lastModel || usage.model;
111
120
  lastEffort = lastEffort || usage.effort;
112
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++;
125
+ }
113
126
  }
114
127
 
115
128
  if (seen.size === 0) return null;
@@ -137,7 +150,10 @@ export function usageForCurrentTurn(transcriptPath) {
137
150
  }
138
151
  const contentExcerpt = replyText ? replyText.slice(0, MAX_EXCERPT_LEN) : null;
139
152
 
140
- return { tokens: totalTokens, model: lastModel, effort: lastEffort, turnKey, contentExcerpt };
153
+ return {
154
+ tokens: totalTokens, model: lastModel, effort: lastEffort, toolUseBlocksSoFar,
155
+ turnKey, contentExcerpt,
156
+ };
141
157
  }
142
158
 
143
159
  function withTimeout(promise, ms) {
@@ -97,6 +97,15 @@ export async function runPreToolUseHook() {
97
97
  // guessed low/medium/high. Omitted when the transcript doesn't carry one
98
98
  // (an older Claude Code build, or a turn before any assistant reply yet).
99
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;
100
109
 
101
110
  const result = await withTimeout(
102
111
  api.evaluateModel(body, apiKey),
@@ -40,6 +40,10 @@ function assistantLineWithText(messageId, usage, text, model = 'claude-sonnet-5'
40
40
  return { type: 'assistant', message: { id: messageId, model, usage, content: [{ type: 'text', text }] } };
41
41
  }
42
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
+
43
47
  const USAGE_A = { input_tokens: 2, output_tokens: 1176, cache_creation_input_tokens: 4134, cache_read_input_tokens: 244103 };
44
48
 
45
49
  describe('extractUsage', () => {
@@ -142,6 +146,29 @@ describe('usageForCurrentTurn', () => {
142
146
  assert.equal(result.effort, null);
143
147
  });
144
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
+
145
172
  it('returns a stable turn_key for the same set of message ids, order-independent', () => {
146
173
  writeLines([
147
174
  { type: 'user', message: { content: [] } },