@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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +20 -2
- package/bin/setup.js +98 -2
- package/lib/guard.js +11 -3
- package/lib/verifier.js +29 -29
- package/package.json +1 -1
- package/skills/setup/SKILL.md +15 -4
|
@@ -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.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 {
|
|
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] =
|
|
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.
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
-
}
|
|
221
|
-
await rm(dataDir, { recursive: true, force: true });
|
|
222
|
-
}
|
|
227
|
+
});
|
|
223
228
|
});
|
|
224
229
|
|
|
225
230
|
await withCheck(result, 'statusline-budget-blocks', async () => {
|
|
226
|
-
|
|
227
|
-
try {
|
|
231
|
+
return withIsolatedPluginEnv(env, root, async (baseEnv) => {
|
|
228
232
|
const checkEnv = {
|
|
229
|
-
...
|
|
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
|
-
}
|
|
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.
|
|
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",
|
package/skills/setup/SKILL.md
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|