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 +1 -1
- package/src/hook-report.js +25 -2
- package/src/mcp-server.js +1 -3
- package/src/pretooluse-hook.js +17 -1
- package/src/tests/hook-report.test.js +61 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "troxy-cli",
|
|
3
|
-
"version": "1.
|
|
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": {
|
package/src/hook-report.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
},
|
package/src/pretooluse-hook.js
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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: [] } },
|