@rex_koh/subagent-budget-guard 0.1.6 → 0.1.8

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.6",
5
+ "version": "0.1.8",
6
6
  "author": {
7
7
  "name": "ClaudeSubAgentSuppressor"
8
8
  },
package/README.md CHANGED
@@ -9,12 +9,12 @@ Recommended Claude Code install:
9
9
  ```text
10
10
  /plugin marketplace add rexkoh425/ClaudeSubAgentSuppressor
11
11
  /plugin install subagent-budget-guard@subagent-budget-tools
12
- /reload-plugins
13
12
  /subagent-budget-guard:setup
14
- /reload-plugins
15
13
  /subagent-budget-guard:verify
16
14
  ```
17
15
 
16
+ After `/subagent-budget-guard:setup`, fully exit and reopen Claude Code before verification so the statusLine bridge from `settings.json` is active. Some Claude Code builds do not provide an in-session plugin reload command.
17
+
18
18
  Useful after install:
19
19
 
20
20
  ```text
@@ -57,4 +57,22 @@ enforcement_enabled=true
57
57
 
58
58
  For existing installs, setup also removes obsolete `max_subagents_per_session` and `max_agent_team_tasks_per_session` options from this plugin's Claude settings.
59
59
 
60
+ The setup skill can also ask for custom values. For direct terminal setup, use:
61
+
62
+ ```bash
63
+ subagent-budget-guard-setup --interactive
64
+ ```
65
+
66
+ Or pass explicit values:
67
+
68
+ ```bash
69
+ subagent-budget-guard-setup \
70
+ --config max_concurrent_subagents=2 \
71
+ --config max_subagent_tokens_per_session=250000 \
72
+ --config subagent_token_warning_threshold_percent=90 \
73
+ --config session_five_hour_budget_percent=15 \
74
+ --config absolute_five_hour_ceiling_percent=95 \
75
+ --config enforcement_enabled=true
76
+ ```
77
+
60
78
  `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/bin/setup.js CHANGED
@@ -1,11 +1,107 @@
1
1
  #!/usr/bin/env node
2
- import { getDataDir, getHomeDir, getPluginRoot, installStatusLineBridge } from '../lib/guard.js';
2
+ import { createInterface } from 'node:readline/promises';
3
+
4
+ import {
5
+ CONFIG_KEYS,
6
+ SETUP_CONFIG,
7
+ buildSetupConfig,
8
+ getDataDir,
9
+ getHomeDir,
10
+ getPluginRoot,
11
+ installStatusLineBridge
12
+ } from '../lib/guard.js';
13
+
14
+ const CONFIG_KEY_SET = new Set(CONFIG_KEYS);
15
+
16
+ function usage() {
17
+ return [
18
+ 'Usage: subagent-budget-guard-setup [--interactive] [--config key=value ...]',
19
+ '',
20
+ 'Config keys:',
21
+ ...CONFIG_KEYS.map((key) => ` ${key} (default ${SETUP_CONFIG[key]})`)
22
+ ].join('\n');
23
+ }
24
+
25
+ function parseConfigPair(pair) {
26
+ const index = pair.indexOf('=');
27
+ if (index <= 0) {
28
+ throw new Error(`Invalid --config value "${pair}". Expected key=value.`);
29
+ }
30
+
31
+ const key = pair.slice(0, index);
32
+ const value = pair.slice(index + 1);
33
+ if (!CONFIG_KEY_SET.has(key)) {
34
+ throw new Error(`Unknown config key "${key}". Valid keys: ${CONFIG_KEYS.join(', ')}`);
35
+ }
36
+ return [key, value];
37
+ }
38
+
39
+ function parseArgs(args) {
40
+ const options = {
41
+ interactive: false,
42
+ overrides: {}
43
+ };
44
+
45
+ for (let index = 0; index < args.length; index += 1) {
46
+ const arg = args[index];
47
+ if (arg === '--help' || arg === '-h') {
48
+ process.stdout.write(`${usage()}\n`);
49
+ process.exit(0);
50
+ }
51
+ if (arg === '--interactive') {
52
+ options.interactive = true;
53
+ continue;
54
+ }
55
+ if (arg === '--config') {
56
+ const pair = args[index + 1];
57
+ if (!pair) throw new Error('--config requires key=value');
58
+ const [key, value] = parseConfigPair(pair);
59
+ options.overrides[key] = value;
60
+ index += 1;
61
+ continue;
62
+ }
63
+ if (arg.startsWith('--config=')) {
64
+ const [key, value] = parseConfigPair(arg.slice('--config='.length));
65
+ options.overrides[key] = value;
66
+ continue;
67
+ }
68
+ throw new Error(`Unknown argument "${arg}".\n${usage()}`);
69
+ }
70
+
71
+ return options;
72
+ }
73
+
74
+ async function promptForConfig(defaults) {
75
+ const rl = createInterface({
76
+ input: process.stdin,
77
+ output: process.stderr
78
+ });
79
+ const answers = {};
80
+
81
+ try {
82
+ for (const key of CONFIG_KEYS) {
83
+ const answer = await rl.question(`${key} [${defaults[key]}]: `);
84
+ if (answer.trim()) {
85
+ answers[key] = answer.trim();
86
+ }
87
+ }
88
+ } finally {
89
+ rl.close();
90
+ }
91
+
92
+ return buildSetupConfig({ ...defaults, ...answers });
93
+ }
3
94
 
4
95
  async function main() {
96
+ const options = parseArgs(process.argv.slice(2));
97
+ const setupConfig = options.interactive
98
+ ? await promptForConfig(buildSetupConfig(options.overrides))
99
+ : buildSetupConfig(options.overrides);
5
100
  const result = await installStatusLineBridge({
6
101
  homeDir: getHomeDir(process.env),
7
102
  pluginRoot: getPluginRoot(process.env),
8
- pluginData: getDataDir(process.env)
103
+ pluginData: getDataDir(process.env),
104
+ setupConfig
9
105
  });
10
106
 
11
107
  process.stdout.write(
package/lib/guard.js CHANGED
@@ -134,6 +134,12 @@ export function loadConfig(env = process.env) {
134
134
  return normalizeConfig(config);
135
135
  }
136
136
 
137
+ export function buildSetupConfig(overrides = {}) {
138
+ const config = { ...SETUP_CONFIG };
139
+ applyConfigValues(config, (key) => overrides[key]);
140
+ return normalizeConfig(config);
141
+ }
142
+
137
143
  export function getHomeDir(env = process.env) {
138
144
  return env.USERPROFILE || env.HOME || os.homedir();
139
145
  }
@@ -823,13 +829,14 @@ function applySetupPluginConfig(
823
829
  ? currentEntry.options
824
830
  : {};
825
831
  const nextOptions = { ...currentOptions };
832
+ const normalizedSetupConfig = buildSetupConfig(setupConfig);
826
833
 
827
834
  for (const key of REMOVED_CONFIG_KEYS) {
828
835
  delete nextOptions[key];
829
836
  }
830
837
 
831
838
  for (const key of CONFIG_KEYS) {
832
- nextOptions[key] = setupConfig[key];
839
+ nextOptions[key] = normalizedSetupConfig[key];
833
840
  }
834
841
 
835
842
  settings.pluginConfigs[pluginId] = {
@@ -843,7 +850,8 @@ function applySetupPluginConfig(
843
850
  export async function installStatusLineBridge({
844
851
  homeDir = getHomeDir(),
845
852
  pluginRoot = getPluginRoot(),
846
- pluginData = getDataDir()
853
+ pluginData = getDataDir(),
854
+ setupConfig = SETUP_CONFIG
847
855
  } = {}) {
848
856
  await mkdir(pluginData, { recursive: true });
849
857
  const { settingsPath, settings } = await ensureSettings(homeDir);
@@ -861,7 +869,7 @@ export async function installStatusLineBridge({
861
869
  padding: existing?.padding ?? previousStatusLine?.padding ?? 0,
862
870
  refreshInterval: existing?.refreshInterval ?? 5
863
871
  };
864
- const pluginConfigOptions = applySetupPluginConfig(settings);
872
+ const pluginConfigOptions = applySetupPluginConfig(settings, { setupConfig });
865
873
 
866
874
  settings.statusLine = nextStatusLine;
867
875
  await writeJsonAtomic(settingsPath, settings);
package/lib/verifier.js CHANGED
@@ -64,6 +64,27 @@ function assert(condition, message) {
64
64
  if (!condition) throw new Error(message);
65
65
  }
66
66
 
67
+ async function withIsolatedPluginEnv(env, root, fn) {
68
+ const dataDir = await mkdtemp(path.join(os.tmpdir(), 'sbg-verify-data-'));
69
+ const homeDir = await mkdtemp(path.join(os.tmpdir(), 'sbg-verify-home-'));
70
+ const checkEnv = {
71
+ PATH: env.PATH,
72
+ Path: env.Path,
73
+ SystemRoot: env.SystemRoot,
74
+ USERPROFILE: homeDir,
75
+ HOME: homeDir,
76
+ CLAUDE_PLUGIN_DATA: dataDir,
77
+ CLAUDE_PLUGIN_ROOT: root
78
+ };
79
+
80
+ try {
81
+ return await fn(checkEnv, { dataDir, homeDir });
82
+ } finally {
83
+ await rm(dataDir, { recursive: true, force: true });
84
+ await rm(homeDir, { recursive: true, force: true });
85
+ }
86
+ }
87
+
67
88
  export async function runOfflineVerification({
68
89
  repoRoot = process.cwd(),
69
90
  env = process.env
@@ -90,7 +111,7 @@ export async function runOfflineVerification({
90
111
  entry.source?.package === '@rex_koh/subagent-budget-guard',
91
112
  'marketplace npm package mismatch'
92
113
  );
93
- assert(entry.source?.version === '0.1.6', 'marketplace npm version mismatch');
114
+ assert(entry.source?.version === '0.1.8', 'marketplace npm version mismatch');
94
115
  return marketplacePath;
95
116
  });
96
117
  } else {
@@ -164,13 +185,7 @@ export async function runOfflineVerification({
164
185
  });
165
186
 
166
187
  await withCheck(result, 'pretool-agent-denies-default', async () => {
167
- const dataDir = await mkdtemp(path.join(os.tmpdir(), 'sbg-verify-'));
168
- try {
169
- const checkEnv = {
170
- ...env,
171
- CLAUDE_PLUGIN_DATA: dataDir,
172
- CLAUDE_PLUGIN_ROOT: root
173
- };
188
+ return withIsolatedPluginEnv(env, root, async (checkEnv) => {
174
189
  const output = await handlePreToolUseAgent(
175
190
  {
176
191
  session_id: 'offline-pretool',
@@ -185,19 +200,11 @@ export async function runOfflineVerification({
185
200
  'Agent launch was not denied by default'
186
201
  );
187
202
  return output.stdout.hookSpecificOutput.permissionDecisionReason;
188
- } finally {
189
- await rm(dataDir, { recursive: true, force: true });
190
- }
203
+ });
191
204
  });
192
205
 
193
206
  await withCheck(result, 'posttool-agent-records-verified-tokens', async () => {
194
- const dataDir = await mkdtemp(path.join(os.tmpdir(), 'sbg-verify-'));
195
- try {
196
- const checkEnv = {
197
- ...env,
198
- CLAUDE_PLUGIN_DATA: dataDir,
199
- CLAUDE_PLUGIN_ROOT: root
200
- };
207
+ return withIsolatedPluginEnv(env, root, async (checkEnv) => {
201
208
  await handlePostToolUseAgent(
202
209
  {
203
210
  session_id: 'offline-posttool',
@@ -217,18 +224,13 @@ export async function runOfflineVerification({
217
224
  const report = await buildReport('offline-posttool', checkEnv);
218
225
  assert(report.state.subagents.verifiedTokens === 101, 'verified token count mismatch');
219
226
  return report.summary.verifiedTokenLabel;
220
- } finally {
221
- await rm(dataDir, { recursive: true, force: true });
222
- }
227
+ });
223
228
  });
224
229
 
225
230
  await withCheck(result, 'statusline-budget-blocks', async () => {
226
- const dataDir = await mkdtemp(path.join(os.tmpdir(), 'sbg-verify-'));
227
- try {
231
+ return withIsolatedPluginEnv(env, root, async (baseEnv) => {
228
232
  const checkEnv = {
229
- ...env,
230
- CLAUDE_PLUGIN_DATA: dataDir,
231
- CLAUDE_PLUGIN_ROOT: root,
233
+ ...baseEnv,
232
234
  CLAUDE_PLUGIN_OPTION_session_five_hour_budget_percent: '3'
233
235
  };
234
236
  await updateRateLimitFromStatusLine(
@@ -255,9 +257,7 @@ export async function runOfflineVerification({
255
257
  );
256
258
  assert(output.stdout?.decision === 'block', 'prompt was not blocked');
257
259
  return output.stdout.reason;
258
- } finally {
259
- await rm(dataDir, { recursive: true, force: true });
260
- }
260
+ });
261
261
  });
262
262
 
263
263
  await withCheck(result, 'statusline-setup-wraps-existing-command', async () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rex_koh/subagent-budget-guard",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
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",
@@ -1,17 +1,16 @@
1
1
  ---
2
2
  description: Install or refresh the Subagent Budget Guard statusLine bridge and apply the recommended plugin config.
3
- disable-model-invocation: true
4
3
  ---
5
4
 
6
5
  # Setup Subagent Budget Guard
7
6
 
8
- Run this command:
7
+ Ask the user whether to use the recommended defaults or customize the values. If they choose defaults, run:
9
8
 
10
9
  ```bash
11
10
  node "${CLAUDE_PLUGIN_ROOT}/bin/setup.js"
12
11
  ```
13
12
 
14
- This applies the recommended config in Claude settings:
13
+ If they choose custom values, ask for each value below. The value in parentheses is the default; accept a blank answer as the default.
15
14
 
16
15
  ```text
17
16
  max_concurrent_subagents=1
@@ -22,7 +21,19 @@ absolute_five_hour_ceiling_percent=95
22
21
  enforcement_enabled=true
23
22
  ```
24
23
 
25
- Then tell the user to run `/reload-plugins`, interact with Claude Code once so the statusLine bridge receives fresh session JSON, and run:
24
+ Then run setup with the chosen values:
25
+
26
+ ```bash
27
+ node "${CLAUDE_PLUGIN_ROOT}/bin/setup.js" \
28
+ --config max_concurrent_subagents=<value> \
29
+ --config max_subagent_tokens_per_session=<value> \
30
+ --config subagent_token_warning_threshold_percent=<value> \
31
+ --config session_five_hour_budget_percent=<value> \
32
+ --config absolute_five_hour_ceiling_percent=<value> \
33
+ --config enforcement_enabled=<true-or-false>
34
+ ```
35
+
36
+ Then tell the user to fully exit and reopen Claude Code, interact once so the statusLine bridge receives fresh session JSON, and run:
26
37
 
27
38
  ```bash
28
39
  node "${CLAUDE_PLUGIN_ROOT}/bin/verify.js" --live