@rex_koh/subagent-budget-guard 0.1.1 → 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 +11 -18
- package/README.md +3 -1
- package/lib/guard.js +108 -30
- 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
|
},
|
|
@@ -15,14 +15,6 @@
|
|
|
15
15
|
"hooks"
|
|
16
16
|
],
|
|
17
17
|
"userConfig": {
|
|
18
|
-
"max_subagents_per_session": {
|
|
19
|
-
"type": "number",
|
|
20
|
-
"title": "Max subagents per session",
|
|
21
|
-
"description": "Maximum normal Agent tool subagents allowed in one Claude Code session. The default 0 blocks all new subagents.",
|
|
22
|
-
"default": 0,
|
|
23
|
-
"min": 0,
|
|
24
|
-
"required": true
|
|
25
|
-
},
|
|
26
18
|
"max_concurrent_subagents": {
|
|
27
19
|
"type": "number",
|
|
28
20
|
"title": "Max concurrent subagents",
|
|
@@ -31,14 +23,6 @@
|
|
|
31
23
|
"min": 0,
|
|
32
24
|
"required": true
|
|
33
25
|
},
|
|
34
|
-
"max_agent_team_tasks_per_session": {
|
|
35
|
-
"type": "number",
|
|
36
|
-
"title": "Max agent-team tasks per session",
|
|
37
|
-
"description": "Maximum agent-team tasks that may be created in one session. The default 0 suppresses agent-team task creation.",
|
|
38
|
-
"default": 0,
|
|
39
|
-
"min": 0,
|
|
40
|
-
"required": true
|
|
41
|
-
},
|
|
42
26
|
"max_subagent_tokens_per_session": {
|
|
43
27
|
"type": "number",
|
|
44
28
|
"title": "Max verified subagent tokens per session",
|
|
@@ -47,6 +31,15 @@
|
|
|
47
31
|
"min": 0,
|
|
48
32
|
"required": true
|
|
49
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
|
+
},
|
|
50
43
|
"session_five_hour_budget_percent": {
|
|
51
44
|
"type": "number",
|
|
52
45
|
"title": "Session 5-hour budget percent",
|
|
@@ -68,7 +61,7 @@
|
|
|
68
61
|
"enforcement_enabled": {
|
|
69
62
|
"type": "boolean",
|
|
70
63
|
"title": "Enable enforcement",
|
|
71
|
-
"description": "When true, hooks deny over-budget subagents
|
|
64
|
+
"description": "When true, hooks deny over-budget subagents and prompts. When false, the plugin records usage without blocking.",
|
|
72
65
|
"default": true,
|
|
73
66
|
"required": true
|
|
74
67
|
}
|
package/README.md
CHANGED
|
@@ -43,4 +43,6 @@ Offline verification:
|
|
|
43
43
|
node bin/verify.js --offline
|
|
44
44
|
```
|
|
45
45
|
|
|
46
|
-
The plugin is strict by default: `
|
|
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
|
@@ -16,10 +16,9 @@ import path from 'node:path';
|
|
|
16
16
|
export const PLUGIN_NAME = 'subagent-budget-guard';
|
|
17
17
|
|
|
18
18
|
export const DEFAULT_CONFIG = Object.freeze({
|
|
19
|
-
max_subagents_per_session: 0,
|
|
20
19
|
max_concurrent_subagents: 0,
|
|
21
|
-
max_agent_team_tasks_per_session: 0,
|
|
22
20
|
max_subagent_tokens_per_session: 0,
|
|
21
|
+
subagent_token_warning_threshold_percent: 95,
|
|
23
22
|
session_five_hour_budget_percent: 25,
|
|
24
23
|
absolute_five_hour_ceiling_percent: 95,
|
|
25
24
|
enforcement_enabled: true
|
|
@@ -80,6 +79,10 @@ export function loadConfig(env = process.env) {
|
|
|
80
79
|
100,
|
|
81
80
|
config.absolute_five_hour_ceiling_percent
|
|
82
81
|
);
|
|
82
|
+
config.subagent_token_warning_threshold_percent = Math.min(
|
|
83
|
+
100,
|
|
84
|
+
Math.max(1, config.subagent_token_warning_threshold_percent)
|
|
85
|
+
);
|
|
83
86
|
|
|
84
87
|
return config;
|
|
85
88
|
}
|
|
@@ -127,6 +130,9 @@ function initialState(sessionId) {
|
|
|
127
130
|
verifiedTokens: 0,
|
|
128
131
|
totalDurationMs: 0,
|
|
129
132
|
totalToolUseCount: 0,
|
|
133
|
+
tokenBudgetWarnings: 0,
|
|
134
|
+
tokenBudgetExceeded: false,
|
|
135
|
+
lastTokenBudgetNoticeAt: null,
|
|
130
136
|
runs: []
|
|
131
137
|
},
|
|
132
138
|
agentTeam: {
|
|
@@ -193,7 +199,23 @@ async function acquireLock(sessionId, env, timeoutMs = 3000) {
|
|
|
193
199
|
}
|
|
194
200
|
|
|
195
201
|
async function readState(sessionId, env) {
|
|
196
|
-
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;
|
|
197
219
|
}
|
|
198
220
|
|
|
199
221
|
async function updateState(sessionId, env, updater) {
|
|
@@ -300,19 +322,61 @@ function fiveHourBudgetDecision(state, config) {
|
|
|
300
322
|
return null;
|
|
301
323
|
}
|
|
302
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
|
+
|
|
303
372
|
function agentDenyReason(state, config) {
|
|
304
373
|
if (!config.enforcement_enabled) return null;
|
|
305
374
|
|
|
306
375
|
const budgetReason = fiveHourBudgetDecision(state, config);
|
|
307
376
|
if (budgetReason) return budgetReason;
|
|
308
377
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
if (state.subagents.allowed >= config.max_subagents_per_session) {
|
|
314
|
-
return `Subagent launch denied: max_subagents_per_session ${config.max_subagents_per_session} already reached.`;
|
|
315
|
-
}
|
|
378
|
+
const tokenBudgetReason = subagentTokenBudgetDecision(state, config);
|
|
379
|
+
if (tokenBudgetReason) return tokenBudgetReason.reason;
|
|
316
380
|
|
|
317
381
|
if (config.max_concurrent_subagents === 0) {
|
|
318
382
|
return 'Subagent launch denied: max_concurrent_subagents is 0.';
|
|
@@ -322,13 +386,6 @@ function agentDenyReason(state, config) {
|
|
|
322
386
|
return `Subagent launch denied: max_concurrent_subagents ${config.max_concurrent_subagents} already reached.`;
|
|
323
387
|
}
|
|
324
388
|
|
|
325
|
-
if (
|
|
326
|
-
config.max_subagent_tokens_per_session > 0 &&
|
|
327
|
-
state.subagents.verifiedTokens >= config.max_subagent_tokens_per_session
|
|
328
|
-
) {
|
|
329
|
-
return `Subagent launch denied: verified subagent tokens ${state.subagents.verifiedTokens} reached max_subagent_tokens_per_session ${config.max_subagent_tokens_per_session}.`;
|
|
330
|
-
}
|
|
331
|
-
|
|
332
389
|
return null;
|
|
333
390
|
}
|
|
334
391
|
|
|
@@ -387,11 +444,13 @@ function usageTotal(usage = {}) {
|
|
|
387
444
|
|
|
388
445
|
export async function handlePostToolUseAgent(input, env = process.env) {
|
|
389
446
|
const sessionId = input?.session_id || 'unknown-session';
|
|
447
|
+
const config = loadConfig(env);
|
|
390
448
|
const response = input?.tool_response || {};
|
|
391
449
|
const status = response.status || 'unknown';
|
|
392
450
|
const totalTokens =
|
|
393
451
|
numberOrNull(response.totalTokens) ?? usageTotal(response.usage || {});
|
|
394
452
|
const verified = status === 'completed' && totalTokens > 0;
|
|
453
|
+
let tokenBudgetNotice = null;
|
|
395
454
|
|
|
396
455
|
await updateState(sessionId, env, (state) => {
|
|
397
456
|
const run = {
|
|
@@ -419,6 +478,14 @@ export async function handlePostToolUseAgent(input, env = process.env) {
|
|
|
419
478
|
state.subagents.verifiedTokens += totalTokens;
|
|
420
479
|
state.subagents.totalDurationMs += run.totalDurationMs;
|
|
421
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
|
+
}
|
|
422
489
|
} else if (status === 'async_launched') {
|
|
423
490
|
state.subagents.backgroundLaunched += 1;
|
|
424
491
|
}
|
|
@@ -430,9 +497,22 @@ export async function handlePostToolUseAgent(input, env = process.env) {
|
|
|
430
497
|
verified,
|
|
431
498
|
totalTokens: run.totalTokens
|
|
432
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
|
+
}
|
|
433
509
|
return state;
|
|
434
510
|
});
|
|
435
511
|
|
|
512
|
+
if (tokenBudgetNotice) {
|
|
513
|
+
return { exitCode: 2, stdout: null, stderr: tokenBudgetNotice.reason };
|
|
514
|
+
}
|
|
515
|
+
|
|
436
516
|
return { exitCode: 0, stdout: null, stderr: '' };
|
|
437
517
|
}
|
|
438
518
|
|
|
@@ -474,14 +554,6 @@ function taskDenyReason(state, config) {
|
|
|
474
554
|
const budgetReason = fiveHourBudgetDecision(state, config);
|
|
475
555
|
if (budgetReason) return budgetReason;
|
|
476
556
|
|
|
477
|
-
if (config.max_agent_team_tasks_per_session === 0) {
|
|
478
|
-
return 'Agent-team task denied: max_agent_team_tasks_per_session is 0.';
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
if (state.agentTeam.created >= config.max_agent_team_tasks_per_session) {
|
|
482
|
-
return `Agent-team task denied: max_agent_team_tasks_per_session ${config.max_agent_team_tasks_per_session} already reached.`;
|
|
483
|
-
}
|
|
484
|
-
|
|
485
557
|
return null;
|
|
486
558
|
}
|
|
487
559
|
|
|
@@ -548,7 +620,10 @@ export async function handleUserPromptSubmit(input, env = process.env) {
|
|
|
548
620
|
const sessionId = input?.session_id || 'unknown-session';
|
|
549
621
|
const config = loadConfig(env);
|
|
550
622
|
const state = await readState(sessionId, env);
|
|
551
|
-
const
|
|
623
|
+
const tokenBudgetReason = subagentTokenBudgetDecision(state, config, {
|
|
624
|
+
includeWarning: false
|
|
625
|
+
});
|
|
626
|
+
const reason = tokenBudgetReason?.reason || fiveHourBudgetDecision(state, config);
|
|
552
627
|
|
|
553
628
|
if (!reason) {
|
|
554
629
|
return { exitCode: 0, stdout: null, stderr: '' };
|
|
@@ -606,6 +681,7 @@ export async function buildReport(sessionId, env = process.env) {
|
|
|
606
681
|
fiveHour.latestUsedPercentage !== null && fiveHour.baselineUsedPercentage !== null
|
|
607
682
|
? Math.max(0, fiveHour.latestUsedPercentage - fiveHour.baselineUsedPercentage)
|
|
608
683
|
: null;
|
|
684
|
+
const tokenBudget = subagentTokenBudgetStatus(state, config);
|
|
609
685
|
|
|
610
686
|
return {
|
|
611
687
|
plugin: PLUGIN_NAME,
|
|
@@ -614,9 +690,10 @@ export async function buildReport(sessionId, env = process.env) {
|
|
|
614
690
|
state,
|
|
615
691
|
summary: {
|
|
616
692
|
verifiedTokenLabel: `${state.subagents.verifiedTokens.toLocaleString('en-US')} verified tokens`,
|
|
617
|
-
|
|
693
|
+
subagentTokenBudget: tokenBudget
|
|
694
|
+
? `${formatCount(tokenBudget.used)}/${formatCount(tokenBudget.limit)} verified tokens (${tokenBudget.percent.toFixed(1)}%)`
|
|
695
|
+
: 'no verified-token cap',
|
|
618
696
|
activeSubagents: `${state.subagents.active}/${config.max_concurrent_subagents}`,
|
|
619
|
-
agentTeamTasks: `${state.agentTeam.created}/${config.max_agent_team_tasks_per_session}`,
|
|
620
697
|
fiveHourBudget:
|
|
621
698
|
consumed === null
|
|
622
699
|
? '5-hour usage unavailable'
|
|
@@ -634,6 +711,7 @@ export function formatReport(report) {
|
|
|
634
711
|
`Enforcement: ${config.enforcement_enabled ? 'enabled' : 'disabled'}`,
|
|
635
712
|
`Subagents: allowed ${state.subagents.allowed}, denied ${state.subagents.denied}, active ${state.subagents.active}, lifecycle starts ${state.subagents.lifecycleStarted}, lifecycle stops ${state.subagents.lifecycleStopped}`,
|
|
636
713
|
`Verified usage: ${summary.verifiedTokenLabel}, ${state.subagents.totalToolUseCount} subagent tool calls, ${state.subagents.totalDurationMs} ms`,
|
|
714
|
+
`Subagent token budget: ${summary.subagentTokenBudget}`,
|
|
637
715
|
`Background launches: ${state.subagents.backgroundLaunched} lifecycle-counted, token totals pending`,
|
|
638
716
|
`Agent-team tasks: created ${state.agentTeam.created}, denied ${state.agentTeam.denied}, completed ${state.agentTeam.completed}`,
|
|
639
717
|
`5-hour budget: ${summary.fiveHourBudget}`
|
|
@@ -771,8 +849,8 @@ export async function renderStatusLine(input, {
|
|
|
771
849
|
|
|
772
850
|
const guardSegment =
|
|
773
851
|
fiveHour.latestUsedPercentage === null
|
|
774
|
-
? `SBG agents ${report.state.subagents.active}/${report.config.max_concurrent_subagents} | 5h unknown`
|
|
775
|
-
: `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)}%`;
|
|
776
854
|
|
|
777
855
|
return previous ? `${previous} | ${guardSegment}` : guardSegment;
|
|
778
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",
|