@rex_koh/subagent-budget-guard 0.1.2 → 0.1.3
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/.claude-plugin/plugin.json +10 -1
- package/README.md +2 -0
- package/lib/guard.js +109 -11
- package/lib/verifier.js +1 -1
- package/package.json +1 -1
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "subagent-budget-guard",
|
|
3
3
|
"displayName": "Subagent Budget Guard",
|
|
4
4
|
"description": "Hard-deny subagent launches, record verified subagent usage, and enforce a session budget against Claude Code's 5-hour rate-limit percentage.",
|
|
5
|
-
"version": "0.1.
|
|
5
|
+
"version": "0.1.3",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "ClaudeSubAgentSuppressor"
|
|
8
8
|
},
|
|
@@ -31,6 +31,15 @@
|
|
|
31
31
|
"min": 0,
|
|
32
32
|
"required": true
|
|
33
33
|
},
|
|
34
|
+
"subagent_token_warning_threshold_percent": {
|
|
35
|
+
"type": "number",
|
|
36
|
+
"title": "Subagent token warning threshold percent",
|
|
37
|
+
"description": "Warn Claude to stop using subagents once verified subagent token usage reaches this percentage of max_subagent_tokens_per_session.",
|
|
38
|
+
"default": 95,
|
|
39
|
+
"min": 1,
|
|
40
|
+
"max": 100,
|
|
41
|
+
"required": true
|
|
42
|
+
},
|
|
34
43
|
"session_five_hour_budget_percent": {
|
|
35
44
|
"type": "number",
|
|
36
45
|
"title": "Session 5-hour budget percent",
|
package/README.md
CHANGED
|
@@ -44,3 +44,5 @@ node bin/verify.js --offline
|
|
|
44
44
|
```
|
|
45
45
|
|
|
46
46
|
The plugin is strict by default: `max_concurrent_subagents` defaults to `0`, so normal subagent launches are blocked unless raised.
|
|
47
|
+
|
|
48
|
+
`max_subagent_tokens_per_session` is enforced from verified `Agent.totalTokens` values after each completed subagent. `subagent_token_warning_threshold_percent` defaults to `95`; once verified subagent usage reaches that percentage, the plugin tells Claude to stop using subagents and blocks future subagent launches. Claude Code does not expose mid-run per-token subagent streaming to hooks, so a single running subagent can only be evaluated when it reports its final token total.
|
package/lib/guard.js
CHANGED
|
@@ -18,6 +18,7 @@ export const PLUGIN_NAME = 'subagent-budget-guard';
|
|
|
18
18
|
export const DEFAULT_CONFIG = Object.freeze({
|
|
19
19
|
max_concurrent_subagents: 0,
|
|
20
20
|
max_subagent_tokens_per_session: 0,
|
|
21
|
+
subagent_token_warning_threshold_percent: 95,
|
|
21
22
|
session_five_hour_budget_percent: 25,
|
|
22
23
|
absolute_five_hour_ceiling_percent: 95,
|
|
23
24
|
enforcement_enabled: true
|
|
@@ -78,6 +79,10 @@ export function loadConfig(env = process.env) {
|
|
|
78
79
|
100,
|
|
79
80
|
config.absolute_five_hour_ceiling_percent
|
|
80
81
|
);
|
|
82
|
+
config.subagent_token_warning_threshold_percent = Math.min(
|
|
83
|
+
100,
|
|
84
|
+
Math.max(1, config.subagent_token_warning_threshold_percent)
|
|
85
|
+
);
|
|
81
86
|
|
|
82
87
|
return config;
|
|
83
88
|
}
|
|
@@ -125,6 +130,9 @@ function initialState(sessionId) {
|
|
|
125
130
|
verifiedTokens: 0,
|
|
126
131
|
totalDurationMs: 0,
|
|
127
132
|
totalToolUseCount: 0,
|
|
133
|
+
tokenBudgetWarnings: 0,
|
|
134
|
+
tokenBudgetExceeded: false,
|
|
135
|
+
lastTokenBudgetNoticeAt: null,
|
|
128
136
|
runs: []
|
|
129
137
|
},
|
|
130
138
|
agentTeam: {
|
|
@@ -191,7 +199,23 @@ async function acquireLock(sessionId, env, timeoutMs = 3000) {
|
|
|
191
199
|
}
|
|
192
200
|
|
|
193
201
|
async function readState(sessionId, env) {
|
|
194
|
-
return readJson(stateFile(sessionId, env), initialState(sessionId));
|
|
202
|
+
return normalizeState(await readJson(stateFile(sessionId, env), initialState(sessionId)), sessionId);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function normalizeState(state, sessionId) {
|
|
206
|
+
const fresh = initialState(sessionId);
|
|
207
|
+
state.subagents = { ...fresh.subagents, ...(state.subagents || {}) };
|
|
208
|
+
state.agentTeam = { ...fresh.agentTeam, ...(state.agentTeam || {}) };
|
|
209
|
+
state.rateLimits = {
|
|
210
|
+
...fresh.rateLimits,
|
|
211
|
+
...(state.rateLimits || {}),
|
|
212
|
+
fiveHour: {
|
|
213
|
+
...fresh.rateLimits.fiveHour,
|
|
214
|
+
...(state.rateLimits?.fiveHour || {})
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
state.events = Array.isArray(state.events) ? state.events : [];
|
|
218
|
+
return state;
|
|
195
219
|
}
|
|
196
220
|
|
|
197
221
|
async function updateState(sessionId, env, updater) {
|
|
@@ -298,12 +322,62 @@ function fiveHourBudgetDecision(state, config) {
|
|
|
298
322
|
return null;
|
|
299
323
|
}
|
|
300
324
|
|
|
325
|
+
function formatCount(value) {
|
|
326
|
+
return Number(value || 0).toLocaleString('en-US');
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function subagentTokenBudgetStatus(state, config) {
|
|
330
|
+
const limit = config.max_subagent_tokens_per_session;
|
|
331
|
+
if (!limit || limit <= 0) return null;
|
|
332
|
+
|
|
333
|
+
const used = Number(state.subagents.verifiedTokens || 0);
|
|
334
|
+
const percent = limit > 0 ? (used / limit) * 100 : 0;
|
|
335
|
+
const warningThreshold = config.subagent_token_warning_threshold_percent;
|
|
336
|
+
|
|
337
|
+
return {
|
|
338
|
+
used,
|
|
339
|
+
limit,
|
|
340
|
+
percent,
|
|
341
|
+
warningThreshold,
|
|
342
|
+
warningTokens: Math.ceil((limit * warningThreshold) / 100),
|
|
343
|
+
atWarning: percent >= warningThreshold,
|
|
344
|
+
atCap: used >= limit
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function subagentTokenBudgetDecision(state, config, { includeWarning = true } = {}) {
|
|
349
|
+
if (!config.enforcement_enabled) return null;
|
|
350
|
+
const status = subagentTokenBudgetStatus(state, config);
|
|
351
|
+
if (!status) return null;
|
|
352
|
+
|
|
353
|
+
if (status.atCap) {
|
|
354
|
+
return {
|
|
355
|
+
severity: 'cap',
|
|
356
|
+
status,
|
|
357
|
+
reason: `Verified subagent token cap reached: ${formatCount(status.used)}/${formatCount(status.limit)} tokens (${status.percent.toFixed(1)}%). Stop using subagents and ask the user before continuing.`
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (includeWarning && status.atWarning) {
|
|
362
|
+
return {
|
|
363
|
+
severity: 'warning',
|
|
364
|
+
status,
|
|
365
|
+
reason: `Verified subagent token usage reached ${status.percent.toFixed(1)}% of the configured cap (${formatCount(status.used)}/${formatCount(status.limit)} tokens; warning threshold ${status.warningThreshold}%). Stop using subagents and ask the user before continuing.`
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
return null;
|
|
370
|
+
}
|
|
371
|
+
|
|
301
372
|
function agentDenyReason(state, config) {
|
|
302
373
|
if (!config.enforcement_enabled) return null;
|
|
303
374
|
|
|
304
375
|
const budgetReason = fiveHourBudgetDecision(state, config);
|
|
305
376
|
if (budgetReason) return budgetReason;
|
|
306
377
|
|
|
378
|
+
const tokenBudgetReason = subagentTokenBudgetDecision(state, config);
|
|
379
|
+
if (tokenBudgetReason) return tokenBudgetReason.reason;
|
|
380
|
+
|
|
307
381
|
if (config.max_concurrent_subagents === 0) {
|
|
308
382
|
return 'Subagent launch denied: max_concurrent_subagents is 0.';
|
|
309
383
|
}
|
|
@@ -312,13 +386,6 @@ function agentDenyReason(state, config) {
|
|
|
312
386
|
return `Subagent launch denied: max_concurrent_subagents ${config.max_concurrent_subagents} already reached.`;
|
|
313
387
|
}
|
|
314
388
|
|
|
315
|
-
if (
|
|
316
|
-
config.max_subagent_tokens_per_session > 0 &&
|
|
317
|
-
state.subagents.verifiedTokens >= config.max_subagent_tokens_per_session
|
|
318
|
-
) {
|
|
319
|
-
return `Subagent launch denied: verified subagent tokens ${state.subagents.verifiedTokens} reached max_subagent_tokens_per_session ${config.max_subagent_tokens_per_session}.`;
|
|
320
|
-
}
|
|
321
|
-
|
|
322
389
|
return null;
|
|
323
390
|
}
|
|
324
391
|
|
|
@@ -377,11 +444,13 @@ function usageTotal(usage = {}) {
|
|
|
377
444
|
|
|
378
445
|
export async function handlePostToolUseAgent(input, env = process.env) {
|
|
379
446
|
const sessionId = input?.session_id || 'unknown-session';
|
|
447
|
+
const config = loadConfig(env);
|
|
380
448
|
const response = input?.tool_response || {};
|
|
381
449
|
const status = response.status || 'unknown';
|
|
382
450
|
const totalTokens =
|
|
383
451
|
numberOrNull(response.totalTokens) ?? usageTotal(response.usage || {});
|
|
384
452
|
const verified = status === 'completed' && totalTokens > 0;
|
|
453
|
+
let tokenBudgetNotice = null;
|
|
385
454
|
|
|
386
455
|
await updateState(sessionId, env, (state) => {
|
|
387
456
|
const run = {
|
|
@@ -409,6 +478,14 @@ export async function handlePostToolUseAgent(input, env = process.env) {
|
|
|
409
478
|
state.subagents.verifiedTokens += totalTokens;
|
|
410
479
|
state.subagents.totalDurationMs += run.totalDurationMs;
|
|
411
480
|
state.subagents.totalToolUseCount += run.totalToolUseCount;
|
|
481
|
+
tokenBudgetNotice = subagentTokenBudgetDecision(state, config);
|
|
482
|
+
if (tokenBudgetNotice) {
|
|
483
|
+
state.subagents.tokenBudgetWarnings += 1;
|
|
484
|
+
state.subagents.lastTokenBudgetNoticeAt = nowIso();
|
|
485
|
+
if (tokenBudgetNotice.severity === 'cap') {
|
|
486
|
+
state.subagents.tokenBudgetExceeded = true;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
412
489
|
} else if (status === 'async_launched') {
|
|
413
490
|
state.subagents.backgroundLaunched += 1;
|
|
414
491
|
}
|
|
@@ -420,9 +497,22 @@ export async function handlePostToolUseAgent(input, env = process.env) {
|
|
|
420
497
|
verified,
|
|
421
498
|
totalTokens: run.totalTokens
|
|
422
499
|
});
|
|
500
|
+
if (tokenBudgetNotice) {
|
|
501
|
+
pushEvent(state, {
|
|
502
|
+
type: 'subagent-token-budget-notice',
|
|
503
|
+
severity: tokenBudgetNotice.severity,
|
|
504
|
+
used: tokenBudgetNotice.status.used,
|
|
505
|
+
limit: tokenBudgetNotice.status.limit,
|
|
506
|
+
percent: tokenBudgetNotice.status.percent
|
|
507
|
+
});
|
|
508
|
+
}
|
|
423
509
|
return state;
|
|
424
510
|
});
|
|
425
511
|
|
|
512
|
+
if (tokenBudgetNotice) {
|
|
513
|
+
return { exitCode: 2, stdout: null, stderr: tokenBudgetNotice.reason };
|
|
514
|
+
}
|
|
515
|
+
|
|
426
516
|
return { exitCode: 0, stdout: null, stderr: '' };
|
|
427
517
|
}
|
|
428
518
|
|
|
@@ -530,7 +620,10 @@ export async function handleUserPromptSubmit(input, env = process.env) {
|
|
|
530
620
|
const sessionId = input?.session_id || 'unknown-session';
|
|
531
621
|
const config = loadConfig(env);
|
|
532
622
|
const state = await readState(sessionId, env);
|
|
533
|
-
const
|
|
623
|
+
const tokenBudgetReason = subagentTokenBudgetDecision(state, config, {
|
|
624
|
+
includeWarning: false
|
|
625
|
+
});
|
|
626
|
+
const reason = tokenBudgetReason?.reason || fiveHourBudgetDecision(state, config);
|
|
534
627
|
|
|
535
628
|
if (!reason) {
|
|
536
629
|
return { exitCode: 0, stdout: null, stderr: '' };
|
|
@@ -588,6 +681,7 @@ export async function buildReport(sessionId, env = process.env) {
|
|
|
588
681
|
fiveHour.latestUsedPercentage !== null && fiveHour.baselineUsedPercentage !== null
|
|
589
682
|
? Math.max(0, fiveHour.latestUsedPercentage - fiveHour.baselineUsedPercentage)
|
|
590
683
|
: null;
|
|
684
|
+
const tokenBudget = subagentTokenBudgetStatus(state, config);
|
|
591
685
|
|
|
592
686
|
return {
|
|
593
687
|
plugin: PLUGIN_NAME,
|
|
@@ -596,6 +690,9 @@ export async function buildReport(sessionId, env = process.env) {
|
|
|
596
690
|
state,
|
|
597
691
|
summary: {
|
|
598
692
|
verifiedTokenLabel: `${state.subagents.verifiedTokens.toLocaleString('en-US')} verified tokens`,
|
|
693
|
+
subagentTokenBudget: tokenBudget
|
|
694
|
+
? `${formatCount(tokenBudget.used)}/${formatCount(tokenBudget.limit)} verified tokens (${tokenBudget.percent.toFixed(1)}%)`
|
|
695
|
+
: 'no verified-token cap',
|
|
599
696
|
activeSubagents: `${state.subagents.active}/${config.max_concurrent_subagents}`,
|
|
600
697
|
fiveHourBudget:
|
|
601
698
|
consumed === null
|
|
@@ -614,6 +711,7 @@ export function formatReport(report) {
|
|
|
614
711
|
`Enforcement: ${config.enforcement_enabled ? 'enabled' : 'disabled'}`,
|
|
615
712
|
`Subagents: allowed ${state.subagents.allowed}, denied ${state.subagents.denied}, active ${state.subagents.active}, lifecycle starts ${state.subagents.lifecycleStarted}, lifecycle stops ${state.subagents.lifecycleStopped}`,
|
|
616
713
|
`Verified usage: ${summary.verifiedTokenLabel}, ${state.subagents.totalToolUseCount} subagent tool calls, ${state.subagents.totalDurationMs} ms`,
|
|
714
|
+
`Subagent token budget: ${summary.subagentTokenBudget}`,
|
|
617
715
|
`Background launches: ${state.subagents.backgroundLaunched} lifecycle-counted, token totals pending`,
|
|
618
716
|
`Agent-team tasks: created ${state.agentTeam.created}, denied ${state.agentTeam.denied}, completed ${state.agentTeam.completed}`,
|
|
619
717
|
`5-hour budget: ${summary.fiveHourBudget}`
|
|
@@ -751,8 +849,8 @@ export async function renderStatusLine(input, {
|
|
|
751
849
|
|
|
752
850
|
const guardSegment =
|
|
753
851
|
fiveHour.latestUsedPercentage === null
|
|
754
|
-
? `SBG agents ${report.state.subagents.active}/${report.config.max_concurrent_subagents} | 5h unknown`
|
|
755
|
-
: `SBG agents ${report.state.subagents.active}/${report.config.max_concurrent_subagents} | 5h ${fiveHour.latestUsedPercentage.toFixed(1)}%`;
|
|
852
|
+
? `SBG agents ${report.state.subagents.active}/${report.config.max_concurrent_subagents} | tokens ${report.summary.subagentTokenBudget} | 5h unknown`
|
|
853
|
+
: `SBG agents ${report.state.subagents.active}/${report.config.max_concurrent_subagents} | tokens ${report.summary.subagentTokenBudget} | 5h ${fiveHour.latestUsedPercentage.toFixed(1)}%`;
|
|
756
854
|
|
|
757
855
|
return previous ? `${previous} | ${guardSegment}` : guardSegment;
|
|
758
856
|
}
|
package/lib/verifier.js
CHANGED
|
@@ -87,7 +87,7 @@ export async function runOfflineVerification({
|
|
|
87
87
|
entry.source?.package === '@rex_koh/subagent-budget-guard',
|
|
88
88
|
'marketplace npm package mismatch'
|
|
89
89
|
);
|
|
90
|
-
assert(entry.source?.version === '0.1.
|
|
90
|
+
assert(entry.source?.version === '0.1.3', 'marketplace npm version mismatch');
|
|
91
91
|
return marketplacePath;
|
|
92
92
|
});
|
|
93
93
|
} else {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rex_koh/subagent-budget-guard",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "Claude Code plugin that blocks subagents by default, records verified subagent usage, and enforces 5-hour usage budgets.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "ClaudeSubAgentSuppressor",
|