@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.
@@ -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.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, agent-team tasks, and prompts. When false, the plugin records usage without blocking.",
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: `max_subagents_per_session`, `max_concurrent_subagents`, and `max_agent_team_tasks_per_session` all default to `0`.
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
- if (config.max_subagents_per_session === 0) {
310
- return 'Subagent launch denied: max_subagents_per_session is 0.';
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 reason = fiveHourBudgetDecision(state, config);
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
- subagentLaunches: `${state.subagents.allowed}/${config.max_subagents_per_session}`,
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.1', '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.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",