@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.
@@ -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.2",
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 reason = fiveHourBudgetDecision(state, config);
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.2', 'marketplace npm version mismatch');
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.2",
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",