@rex_koh/subagent-budget-guard 0.1.0
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 +76 -0
- package/LICENSE +21 -0
- package/README.md +46 -0
- package/bin/hook.js +56 -0
- package/bin/report.js +23 -0
- package/bin/setup.js +26 -0
- package/bin/statusline.js +28 -0
- package/bin/verify.js +27 -0
- package/hooks/hooks.json +120 -0
- package/lib/guard.js +787 -0
- package/lib/verifier.js +373 -0
- package/package.json +51 -0
- package/skills/report/SKILL.md +18 -0
- package/skills/setup/SKILL.md +20 -0
- package/skills/verify/SKILL.md +20 -0
package/lib/guard.js
ADDED
|
@@ -0,0 +1,787 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { constants as fsConstants } from 'node:fs';
|
|
3
|
+
import {
|
|
4
|
+
access,
|
|
5
|
+
mkdir,
|
|
6
|
+
open,
|
|
7
|
+
readFile,
|
|
8
|
+
readdir,
|
|
9
|
+
rename,
|
|
10
|
+
rm,
|
|
11
|
+
stat,
|
|
12
|
+
writeFile
|
|
13
|
+
} from 'node:fs/promises';
|
|
14
|
+
import os from 'node:os';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
export const PLUGIN_NAME = 'subagent-budget-guard';
|
|
17
|
+
|
|
18
|
+
export const DEFAULT_CONFIG = Object.freeze({
|
|
19
|
+
max_subagents_per_session: 0,
|
|
20
|
+
max_concurrent_subagents: 0,
|
|
21
|
+
max_agent_team_tasks_per_session: 0,
|
|
22
|
+
max_subagent_tokens_per_session: 0,
|
|
23
|
+
session_five_hour_budget_percent: 25,
|
|
24
|
+
absolute_five_hour_ceiling_percent: 95,
|
|
25
|
+
enforcement_enabled: true
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
export const CONFIG_KEYS = Object.freeze(Object.keys(DEFAULT_CONFIG));
|
|
29
|
+
|
|
30
|
+
const NUMBER_KEYS = new Set(
|
|
31
|
+
CONFIG_KEYS.filter((key) => typeof DEFAULT_CONFIG[key] === 'number')
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
function nowIso() {
|
|
35
|
+
return new Date().toISOString();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function sanitizeId(value) {
|
|
39
|
+
return String(value || 'unknown-session').replace(/[^a-zA-Z0-9._-]/g, '-');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function asNumber(value, fallback) {
|
|
43
|
+
if (value === undefined || value === null || value === '') return fallback;
|
|
44
|
+
const number = Number(value);
|
|
45
|
+
return Number.isFinite(number) ? number : fallback;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function asBoolean(value, fallback) {
|
|
49
|
+
if (value === undefined || value === null || value === '') return fallback;
|
|
50
|
+
if (typeof value === 'boolean') return value;
|
|
51
|
+
const normalized = String(value).trim().toLowerCase();
|
|
52
|
+
if (['1', 'true', 'yes', 'on'].includes(normalized)) return true;
|
|
53
|
+
if (['0', 'false', 'no', 'off'].includes(normalized)) return false;
|
|
54
|
+
return fallback;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function envValue(env, key) {
|
|
58
|
+
const exact = `CLAUDE_PLUGIN_OPTION_${key}`;
|
|
59
|
+
const upper = `CLAUDE_PLUGIN_OPTION_${key.toUpperCase()}`;
|
|
60
|
+
return env[exact] ?? env[upper];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function loadConfig(env = process.env) {
|
|
64
|
+
const config = { ...DEFAULT_CONFIG };
|
|
65
|
+
|
|
66
|
+
for (const key of CONFIG_KEYS) {
|
|
67
|
+
const value = envValue(env, key);
|
|
68
|
+
if (NUMBER_KEYS.has(key)) {
|
|
69
|
+
config[key] = Math.max(0, asNumber(value, DEFAULT_CONFIG[key]));
|
|
70
|
+
} else if (typeof DEFAULT_CONFIG[key] === 'boolean') {
|
|
71
|
+
config[key] = asBoolean(value, DEFAULT_CONFIG[key]);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
config.session_five_hour_budget_percent = Math.min(
|
|
76
|
+
100,
|
|
77
|
+
config.session_five_hour_budget_percent
|
|
78
|
+
);
|
|
79
|
+
config.absolute_five_hour_ceiling_percent = Math.min(
|
|
80
|
+
100,
|
|
81
|
+
config.absolute_five_hour_ceiling_percent
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
return config;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function getHomeDir(env = process.env) {
|
|
88
|
+
return env.USERPROFILE || env.HOME || os.homedir();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function getPluginRoot(env = process.env) {
|
|
92
|
+
return env.CLAUDE_PLUGIN_ROOT || path.resolve('.');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function getDataDir(env = process.env) {
|
|
96
|
+
if (env.CLAUDE_PLUGIN_DATA) return env.CLAUDE_PLUGIN_DATA;
|
|
97
|
+
return path.join(getHomeDir(env), '.claude', 'plugins', 'data', PLUGIN_NAME);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function stateDir(env) {
|
|
101
|
+
return path.join(getDataDir(env), 'sessions');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function stateFile(sessionId, env) {
|
|
105
|
+
return path.join(stateDir(env), `${sanitizeId(sessionId)}.json`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function lockFile(sessionId, env) {
|
|
109
|
+
return `${stateFile(sessionId, env)}.lock`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function initialState(sessionId) {
|
|
113
|
+
return {
|
|
114
|
+
schemaVersion: 1,
|
|
115
|
+
sessionId,
|
|
116
|
+
createdAt: nowIso(),
|
|
117
|
+
updatedAt: nowIso(),
|
|
118
|
+
subagents: {
|
|
119
|
+
requested: 0,
|
|
120
|
+
allowed: 0,
|
|
121
|
+
denied: 0,
|
|
122
|
+
active: 0,
|
|
123
|
+
completed: 0,
|
|
124
|
+
backgroundLaunched: 0,
|
|
125
|
+
lifecycleStarted: 0,
|
|
126
|
+
lifecycleStopped: 0,
|
|
127
|
+
verifiedTokens: 0,
|
|
128
|
+
totalDurationMs: 0,
|
|
129
|
+
totalToolUseCount: 0,
|
|
130
|
+
runs: []
|
|
131
|
+
},
|
|
132
|
+
agentTeam: {
|
|
133
|
+
created: 0,
|
|
134
|
+
completed: 0,
|
|
135
|
+
denied: 0,
|
|
136
|
+
active: 0,
|
|
137
|
+
tasks: []
|
|
138
|
+
},
|
|
139
|
+
rateLimits: {
|
|
140
|
+
fiveHour: {
|
|
141
|
+
baselineUsedPercentage: null,
|
|
142
|
+
latestUsedPercentage: null,
|
|
143
|
+
latestObservedAt: null,
|
|
144
|
+
resetsAt: null,
|
|
145
|
+
bridgeSeen: false
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
events: []
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function readJson(filePath, fallback = null) {
|
|
153
|
+
try {
|
|
154
|
+
const text = await readFile(filePath, 'utf8');
|
|
155
|
+
return JSON.parse(text.replace(/^\uFEFF/, ''));
|
|
156
|
+
} catch (error) {
|
|
157
|
+
if (error.code === 'ENOENT') return fallback;
|
|
158
|
+
throw error;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function writeJsonAtomic(filePath, value) {
|
|
163
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
164
|
+
const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
165
|
+
await writeFile(tmpPath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
|
|
166
|
+
await rename(tmpPath, filePath);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function sleep(ms) {
|
|
170
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function acquireLock(sessionId, env, timeoutMs = 3000) {
|
|
174
|
+
await mkdir(stateDir(env), { recursive: true });
|
|
175
|
+
const filePath = lockFile(sessionId, env);
|
|
176
|
+
const start = Date.now();
|
|
177
|
+
|
|
178
|
+
while (true) {
|
|
179
|
+
try {
|
|
180
|
+
const handle = await open(filePath, 'wx');
|
|
181
|
+
return async () => {
|
|
182
|
+
await handle.close();
|
|
183
|
+
await rm(filePath, { force: true });
|
|
184
|
+
};
|
|
185
|
+
} catch (error) {
|
|
186
|
+
if (error.code !== 'EEXIST') throw error;
|
|
187
|
+
if (Date.now() - start > timeoutMs) {
|
|
188
|
+
throw new Error(`Timed out waiting for state lock: ${filePath}`);
|
|
189
|
+
}
|
|
190
|
+
await sleep(25);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function readState(sessionId, env) {
|
|
196
|
+
return readJson(stateFile(sessionId, env), initialState(sessionId));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function updateState(sessionId, env, updater) {
|
|
200
|
+
const release = await acquireLock(sessionId, env);
|
|
201
|
+
try {
|
|
202
|
+
const current = await readState(sessionId, env);
|
|
203
|
+
const next = await updater(current) || current;
|
|
204
|
+
next.updatedAt = nowIso();
|
|
205
|
+
await writeJsonAtomic(stateFile(sessionId, env), next);
|
|
206
|
+
return next;
|
|
207
|
+
} finally {
|
|
208
|
+
await release();
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function pushEvent(state, event) {
|
|
213
|
+
state.events.push({
|
|
214
|
+
at: nowIso(),
|
|
215
|
+
...event
|
|
216
|
+
});
|
|
217
|
+
if (state.events.length > 200) {
|
|
218
|
+
state.events = state.events.slice(-200);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function numberOrNull(value) {
|
|
223
|
+
const number = Number(value);
|
|
224
|
+
return Number.isFinite(number) ? number : null;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function fiveHourUsage(input) {
|
|
228
|
+
const fiveHour = input?.rate_limits?.five_hour;
|
|
229
|
+
if (!fiveHour) return null;
|
|
230
|
+
|
|
231
|
+
const used = numberOrNull(fiveHour.used_percentage);
|
|
232
|
+
if (used === null) return null;
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
usedPercentage: Math.max(0, Math.min(100, used)),
|
|
236
|
+
resetsAt: numberOrNull(fiveHour.resets_at)
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export async function updateRateLimitFromStatusLine(input, env = process.env) {
|
|
241
|
+
const sessionId = input?.session_id || input?.sessionId || 'unknown-session';
|
|
242
|
+
const usage = fiveHourUsage(input);
|
|
243
|
+
|
|
244
|
+
if (!usage) {
|
|
245
|
+
return updateState(sessionId, env, (state) => {
|
|
246
|
+
state.rateLimits.fiveHour.bridgeSeen = true;
|
|
247
|
+
pushEvent(state, { type: 'statusline-no-five-hour-rate-limit' });
|
|
248
|
+
return state;
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return updateState(sessionId, env, (state) => {
|
|
253
|
+
const fiveHour = state.rateLimits.fiveHour;
|
|
254
|
+
const resetChanged =
|
|
255
|
+
fiveHour.resetsAt !== null &&
|
|
256
|
+
usage.resetsAt !== null &&
|
|
257
|
+
usage.resetsAt !== fiveHour.resetsAt;
|
|
258
|
+
const usageRolledOver =
|
|
259
|
+
fiveHour.baselineUsedPercentage !== null &&
|
|
260
|
+
usage.usedPercentage < fiveHour.baselineUsedPercentage - 0.1;
|
|
261
|
+
|
|
262
|
+
if (
|
|
263
|
+
fiveHour.baselineUsedPercentage === null ||
|
|
264
|
+
resetChanged ||
|
|
265
|
+
usageRolledOver
|
|
266
|
+
) {
|
|
267
|
+
fiveHour.baselineUsedPercentage = usage.usedPercentage;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
fiveHour.latestUsedPercentage = usage.usedPercentage;
|
|
271
|
+
fiveHour.resetsAt = usage.resetsAt;
|
|
272
|
+
fiveHour.latestObservedAt = nowIso();
|
|
273
|
+
fiveHour.bridgeSeen = true;
|
|
274
|
+
pushEvent(state, {
|
|
275
|
+
type: 'statusline-rate-limit',
|
|
276
|
+
usedPercentage: usage.usedPercentage,
|
|
277
|
+
resetsAt: usage.resetsAt
|
|
278
|
+
});
|
|
279
|
+
return state;
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function fiveHourBudgetDecision(state, config) {
|
|
284
|
+
const fiveHour = state.rateLimits.fiveHour;
|
|
285
|
+
const latest = fiveHour.latestUsedPercentage;
|
|
286
|
+
const baseline = fiveHour.baselineUsedPercentage;
|
|
287
|
+
|
|
288
|
+
if (!config.enforcement_enabled) return null;
|
|
289
|
+
if (latest === null || baseline === null) return null;
|
|
290
|
+
|
|
291
|
+
if (latest >= config.absolute_five_hour_ceiling_percent) {
|
|
292
|
+
return `5-hour usage ${latest.toFixed(1)}% reached the absolute ceiling ${config.absolute_five_hour_ceiling_percent}%.`;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const consumed = latest - baseline;
|
|
296
|
+
if (consumed >= config.session_five_hour_budget_percent) {
|
|
297
|
+
return `5-hour budget exhausted: this session used ${consumed.toFixed(1)} percentage points since baseline ${baseline.toFixed(1)}%, limit ${config.session_five_hour_budget_percent}%.`;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function agentDenyReason(state, config) {
|
|
304
|
+
if (!config.enforcement_enabled) return null;
|
|
305
|
+
|
|
306
|
+
const budgetReason = fiveHourBudgetDecision(state, config);
|
|
307
|
+
if (budgetReason) return budgetReason;
|
|
308
|
+
|
|
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
|
+
}
|
|
316
|
+
|
|
317
|
+
if (config.max_concurrent_subagents === 0) {
|
|
318
|
+
return 'Subagent launch denied: max_concurrent_subagents is 0.';
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (state.subagents.active >= config.max_concurrent_subagents) {
|
|
322
|
+
return `Subagent launch denied: max_concurrent_subagents ${config.max_concurrent_subagents} already reached.`;
|
|
323
|
+
}
|
|
324
|
+
|
|
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
|
+
return null;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export async function handlePreToolUseAgent(input, env = process.env) {
|
|
336
|
+
const sessionId = input?.session_id || 'unknown-session';
|
|
337
|
+
const config = loadConfig(env);
|
|
338
|
+
let reason = null;
|
|
339
|
+
|
|
340
|
+
await updateState(sessionId, env, (state) => {
|
|
341
|
+
state.subagents.requested += 1;
|
|
342
|
+
reason = agentDenyReason(state, config);
|
|
343
|
+
if (reason) {
|
|
344
|
+
state.subagents.denied += 1;
|
|
345
|
+
pushEvent(state, {
|
|
346
|
+
type: 'agent-denied',
|
|
347
|
+
reason,
|
|
348
|
+
description: input?.tool_input?.description || null,
|
|
349
|
+
subagentType: input?.tool_input?.subagent_type || null
|
|
350
|
+
});
|
|
351
|
+
} else {
|
|
352
|
+
state.subagents.allowed += 1;
|
|
353
|
+
pushEvent(state, {
|
|
354
|
+
type: 'agent-allowed',
|
|
355
|
+
description: input?.tool_input?.description || null,
|
|
356
|
+
subagentType: input?.tool_input?.subagent_type || null
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
return state;
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
if (!reason) {
|
|
363
|
+
return { exitCode: 0, stdout: null, stderr: '' };
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
return {
|
|
367
|
+
exitCode: 0,
|
|
368
|
+
stdout: {
|
|
369
|
+
hookSpecificOutput: {
|
|
370
|
+
hookEventName: 'PreToolUse',
|
|
371
|
+
permissionDecision: 'deny',
|
|
372
|
+
permissionDecisionReason: reason
|
|
373
|
+
}
|
|
374
|
+
},
|
|
375
|
+
stderr: ''
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function usageTotal(usage = {}) {
|
|
380
|
+
return (
|
|
381
|
+
asNumber(usage.input_tokens, 0) +
|
|
382
|
+
asNumber(usage.output_tokens, 0) +
|
|
383
|
+
asNumber(usage.cache_creation_input_tokens, 0) +
|
|
384
|
+
asNumber(usage.cache_read_input_tokens, 0)
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
export async function handlePostToolUseAgent(input, env = process.env) {
|
|
389
|
+
const sessionId = input?.session_id || 'unknown-session';
|
|
390
|
+
const response = input?.tool_response || {};
|
|
391
|
+
const status = response.status || 'unknown';
|
|
392
|
+
const totalTokens =
|
|
393
|
+
numberOrNull(response.totalTokens) ?? usageTotal(response.usage || {});
|
|
394
|
+
const verified = status === 'completed' && totalTokens > 0;
|
|
395
|
+
|
|
396
|
+
await updateState(sessionId, env, (state) => {
|
|
397
|
+
const run = {
|
|
398
|
+
at: nowIso(),
|
|
399
|
+
agentId: response.agentId || null,
|
|
400
|
+
status,
|
|
401
|
+
description: input?.tool_input?.description || null,
|
|
402
|
+
subagentType: input?.tool_input?.subagent_type || null,
|
|
403
|
+
resolvedModel: response.resolvedModel || null,
|
|
404
|
+
totalTokens: verified ? totalTokens : 0,
|
|
405
|
+
totalDurationMs: asNumber(response.totalDurationMs, 0),
|
|
406
|
+
totalToolUseCount: asNumber(response.totalToolUseCount, 0),
|
|
407
|
+
verified,
|
|
408
|
+
usage: response.usage || null,
|
|
409
|
+
outputFile: response.outputFile || null
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
state.subagents.runs.push(run);
|
|
413
|
+
if (state.subagents.runs.length > 100) {
|
|
414
|
+
state.subagents.runs = state.subagents.runs.slice(-100);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
if (verified) {
|
|
418
|
+
state.subagents.completed += 1;
|
|
419
|
+
state.subagents.verifiedTokens += totalTokens;
|
|
420
|
+
state.subagents.totalDurationMs += run.totalDurationMs;
|
|
421
|
+
state.subagents.totalToolUseCount += run.totalToolUseCount;
|
|
422
|
+
} else if (status === 'async_launched') {
|
|
423
|
+
state.subagents.backgroundLaunched += 1;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
pushEvent(state, {
|
|
427
|
+
type: 'agent-result',
|
|
428
|
+
status,
|
|
429
|
+
agentId: run.agentId,
|
|
430
|
+
verified,
|
|
431
|
+
totalTokens: run.totalTokens
|
|
432
|
+
});
|
|
433
|
+
return state;
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
return { exitCode: 0, stdout: null, stderr: '' };
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
export async function handleSubagentStart(input, env = process.env) {
|
|
440
|
+
const sessionId = input?.session_id || 'unknown-session';
|
|
441
|
+
await updateState(sessionId, env, (state) => {
|
|
442
|
+
state.subagents.lifecycleStarted += 1;
|
|
443
|
+
state.subagents.active += 1;
|
|
444
|
+
pushEvent(state, {
|
|
445
|
+
type: 'subagent-start',
|
|
446
|
+
agentId: input?.agent_id || null,
|
|
447
|
+
agentType: input?.agent_type || null
|
|
448
|
+
});
|
|
449
|
+
return state;
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
return { exitCode: 0, stdout: null, stderr: '' };
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
export async function handleSubagentStop(input, env = process.env) {
|
|
456
|
+
const sessionId = input?.session_id || 'unknown-session';
|
|
457
|
+
await updateState(sessionId, env, (state) => {
|
|
458
|
+
state.subagents.lifecycleStopped += 1;
|
|
459
|
+
state.subagents.active = Math.max(0, state.subagents.active - 1);
|
|
460
|
+
pushEvent(state, {
|
|
461
|
+
type: 'subagent-stop',
|
|
462
|
+
agentId: input?.agent_id || null,
|
|
463
|
+
agentType: input?.agent_type || null
|
|
464
|
+
});
|
|
465
|
+
return state;
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
return { exitCode: 0, stdout: null, stderr: '' };
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function taskDenyReason(state, config) {
|
|
472
|
+
if (!config.enforcement_enabled) return null;
|
|
473
|
+
|
|
474
|
+
const budgetReason = fiveHourBudgetDecision(state, config);
|
|
475
|
+
if (budgetReason) return budgetReason;
|
|
476
|
+
|
|
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
|
+
return null;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
export async function handleTaskCreated(input, env = process.env) {
|
|
489
|
+
const sessionId = input?.session_id || 'unknown-session';
|
|
490
|
+
const config = loadConfig(env);
|
|
491
|
+
let reason = null;
|
|
492
|
+
|
|
493
|
+
await updateState(sessionId, env, (state) => {
|
|
494
|
+
reason = taskDenyReason(state, config);
|
|
495
|
+
if (reason) {
|
|
496
|
+
state.agentTeam.denied += 1;
|
|
497
|
+
pushEvent(state, {
|
|
498
|
+
type: 'task-denied',
|
|
499
|
+
taskId: input?.task_id || null,
|
|
500
|
+
reason
|
|
501
|
+
});
|
|
502
|
+
} else {
|
|
503
|
+
state.agentTeam.created += 1;
|
|
504
|
+
state.agentTeam.active += 1;
|
|
505
|
+
state.agentTeam.tasks.push({
|
|
506
|
+
taskId: input?.task_id || null,
|
|
507
|
+
subject: input?.task_subject || null,
|
|
508
|
+
description: input?.task_description || null,
|
|
509
|
+
createdAt: nowIso(),
|
|
510
|
+
completedAt: null
|
|
511
|
+
});
|
|
512
|
+
pushEvent(state, {
|
|
513
|
+
type: 'task-created',
|
|
514
|
+
taskId: input?.task_id || null,
|
|
515
|
+
subject: input?.task_subject || null
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
return state;
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
if (reason) {
|
|
522
|
+
return { exitCode: 2, stdout: null, stderr: reason };
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
return { exitCode: 0, stdout: null, stderr: '' };
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
export async function handleTaskCompleted(input, env = process.env) {
|
|
529
|
+
const sessionId = input?.session_id || 'unknown-session';
|
|
530
|
+
|
|
531
|
+
await updateState(sessionId, env, (state) => {
|
|
532
|
+
state.agentTeam.completed += 1;
|
|
533
|
+
state.agentTeam.active = Math.max(0, state.agentTeam.active - 1);
|
|
534
|
+
const task = state.agentTeam.tasks.find((item) => item.taskId === input?.task_id);
|
|
535
|
+
if (task) task.completedAt = nowIso();
|
|
536
|
+
pushEvent(state, {
|
|
537
|
+
type: 'task-completed',
|
|
538
|
+
taskId: input?.task_id || null,
|
|
539
|
+
subject: input?.task_subject || null
|
|
540
|
+
});
|
|
541
|
+
return state;
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
return { exitCode: 0, stdout: null, stderr: '' };
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
export async function handleUserPromptSubmit(input, env = process.env) {
|
|
548
|
+
const sessionId = input?.session_id || 'unknown-session';
|
|
549
|
+
const config = loadConfig(env);
|
|
550
|
+
const state = await readState(sessionId, env);
|
|
551
|
+
const reason = fiveHourBudgetDecision(state, config);
|
|
552
|
+
|
|
553
|
+
if (!reason) {
|
|
554
|
+
return { exitCode: 0, stdout: null, stderr: '' };
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
await updateState(sessionId, env, (nextState) => {
|
|
558
|
+
pushEvent(nextState, { type: 'prompt-denied', reason });
|
|
559
|
+
return nextState;
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
return {
|
|
563
|
+
exitCode: 0,
|
|
564
|
+
stdout: {
|
|
565
|
+
decision: 'block',
|
|
566
|
+
reason,
|
|
567
|
+
suppressOriginalPrompt: true
|
|
568
|
+
},
|
|
569
|
+
stderr: ''
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
async function listSessionIds(env = process.env) {
|
|
574
|
+
try {
|
|
575
|
+
const entries = await readdir(stateDir(env));
|
|
576
|
+
const jsonEntries = await Promise.all(
|
|
577
|
+
entries
|
|
578
|
+
.filter((entry) => entry.endsWith('.json'))
|
|
579
|
+
.map(async (entry) => {
|
|
580
|
+
const filePath = path.join(stateDir(env), entry);
|
|
581
|
+
const fileStat = await stat(filePath);
|
|
582
|
+
return {
|
|
583
|
+
sessionId: entry.slice(0, -'.json'.length),
|
|
584
|
+
mtimeMs: fileStat.mtimeMs
|
|
585
|
+
};
|
|
586
|
+
})
|
|
587
|
+
);
|
|
588
|
+
return jsonEntries.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
589
|
+
} catch (error) {
|
|
590
|
+
if (error.code === 'ENOENT') return [];
|
|
591
|
+
throw error;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
export async function latestSessionId(env = process.env) {
|
|
596
|
+
const sessions = await listSessionIds(env);
|
|
597
|
+
return sessions[0]?.sessionId || null;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
export async function buildReport(sessionId, env = process.env) {
|
|
601
|
+
const resolvedSessionId = sessionId || (await latestSessionId(env)) || 'unknown-session';
|
|
602
|
+
const state = await readState(resolvedSessionId, env);
|
|
603
|
+
const config = loadConfig(env);
|
|
604
|
+
const fiveHour = state.rateLimits.fiveHour;
|
|
605
|
+
const consumed =
|
|
606
|
+
fiveHour.latestUsedPercentage !== null && fiveHour.baselineUsedPercentage !== null
|
|
607
|
+
? Math.max(0, fiveHour.latestUsedPercentage - fiveHour.baselineUsedPercentage)
|
|
608
|
+
: null;
|
|
609
|
+
|
|
610
|
+
return {
|
|
611
|
+
plugin: PLUGIN_NAME,
|
|
612
|
+
sessionId: resolvedSessionId,
|
|
613
|
+
config,
|
|
614
|
+
state,
|
|
615
|
+
summary: {
|
|
616
|
+
verifiedTokenLabel: `${state.subagents.verifiedTokens.toLocaleString('en-US')} verified tokens`,
|
|
617
|
+
subagentLaunches: `${state.subagents.allowed}/${config.max_subagents_per_session}`,
|
|
618
|
+
activeSubagents: `${state.subagents.active}/${config.max_concurrent_subagents}`,
|
|
619
|
+
agentTeamTasks: `${state.agentTeam.created}/${config.max_agent_team_tasks_per_session}`,
|
|
620
|
+
fiveHourBudget:
|
|
621
|
+
consumed === null
|
|
622
|
+
? '5-hour usage unavailable'
|
|
623
|
+
: `${consumed.toFixed(1)}/${config.session_five_hour_budget_percent}% points used since baseline`,
|
|
624
|
+
bridgeSeen: fiveHour.bridgeSeen
|
|
625
|
+
}
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
export function formatReport(report) {
|
|
630
|
+
const { state, config, summary } = report;
|
|
631
|
+
const fiveHour = state.rateLimits.fiveHour;
|
|
632
|
+
const lines = [
|
|
633
|
+
`Subagent Budget Guard report for ${report.sessionId}`,
|
|
634
|
+
`Enforcement: ${config.enforcement_enabled ? 'enabled' : 'disabled'}`,
|
|
635
|
+
`Subagents: allowed ${state.subagents.allowed}, denied ${state.subagents.denied}, active ${state.subagents.active}, lifecycle starts ${state.subagents.lifecycleStarted}, lifecycle stops ${state.subagents.lifecycleStopped}`,
|
|
636
|
+
`Verified usage: ${summary.verifiedTokenLabel}, ${state.subagents.totalToolUseCount} subagent tool calls, ${state.subagents.totalDurationMs} ms`,
|
|
637
|
+
`Background launches: ${state.subagents.backgroundLaunched} lifecycle-counted, token totals pending`,
|
|
638
|
+
`Agent-team tasks: created ${state.agentTeam.created}, denied ${state.agentTeam.denied}, completed ${state.agentTeam.completed}`,
|
|
639
|
+
`5-hour budget: ${summary.fiveHourBudget}`
|
|
640
|
+
];
|
|
641
|
+
|
|
642
|
+
if (fiveHour.latestUsedPercentage !== null) {
|
|
643
|
+
lines.push(
|
|
644
|
+
`5-hour latest: ${fiveHour.latestUsedPercentage.toFixed(1)}%, baseline ${fiveHour.baselineUsedPercentage.toFixed(1)}%, resets_at ${fiveHour.resetsAt ?? 'unknown'}`
|
|
645
|
+
);
|
|
646
|
+
} else {
|
|
647
|
+
lines.push(
|
|
648
|
+
'5-hour latest: unavailable. Run /subagent-budget-guard:setup so the statusLine bridge can capture rate_limits.five_hour.used_percentage.'
|
|
649
|
+
);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
return lines.join('\n');
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function quoteShellArg(value) {
|
|
656
|
+
const normalized = String(value).replace(/\\/g, '/').replace(/"/g, '\\"');
|
|
657
|
+
return `"${normalized}"`;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
function bridgeCommand(pluginRoot, pluginData) {
|
|
661
|
+
const statuslinePath = path.join(pluginRoot, 'bin', 'statusline.js');
|
|
662
|
+
return `node ${quoteShellArg(statuslinePath)} --data ${quoteShellArg(pluginData)}`;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
async function ensureSettings(homeDir) {
|
|
666
|
+
const claudeDir = path.join(homeDir, '.claude');
|
|
667
|
+
const settingsPath = path.join(claudeDir, 'settings.json');
|
|
668
|
+
await mkdir(claudeDir, { recursive: true });
|
|
669
|
+
const settings = await readJson(settingsPath, {});
|
|
670
|
+
return { settingsPath, settings };
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function isBridgeStatusLine(statusLine) {
|
|
674
|
+
return (
|
|
675
|
+
statusLine &&
|
|
676
|
+
typeof statusLine.command === 'string' &&
|
|
677
|
+
statusLine.command.includes('statusline.js') &&
|
|
678
|
+
statusLine.command.includes('--data')
|
|
679
|
+
);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
export async function installStatusLineBridge({
|
|
683
|
+
homeDir = getHomeDir(),
|
|
684
|
+
pluginRoot = getPluginRoot(),
|
|
685
|
+
pluginData = getDataDir()
|
|
686
|
+
} = {}) {
|
|
687
|
+
await mkdir(pluginData, { recursive: true });
|
|
688
|
+
const { settingsPath, settings } = await ensureSettings(homeDir);
|
|
689
|
+
const bridgePath = path.join(pluginData, 'statusline-bridge.json');
|
|
690
|
+
const previousBridge = await readJson(bridgePath, {});
|
|
691
|
+
const existing = settings.statusLine || null;
|
|
692
|
+
const previousStatusLine = isBridgeStatusLine(existing)
|
|
693
|
+
? previousBridge.previousStatusLine || null
|
|
694
|
+
: existing;
|
|
695
|
+
|
|
696
|
+
const command = bridgeCommand(pluginRoot, pluginData);
|
|
697
|
+
const nextStatusLine = {
|
|
698
|
+
type: 'command',
|
|
699
|
+
command,
|
|
700
|
+
padding: existing?.padding ?? previousStatusLine?.padding ?? 0,
|
|
701
|
+
refreshInterval: existing?.refreshInterval ?? 5
|
|
702
|
+
};
|
|
703
|
+
|
|
704
|
+
settings.statusLine = nextStatusLine;
|
|
705
|
+
await writeJsonAtomic(settingsPath, settings);
|
|
706
|
+
await writeJsonAtomic(bridgePath, {
|
|
707
|
+
installedAt: nowIso(),
|
|
708
|
+
pluginRoot,
|
|
709
|
+
pluginData,
|
|
710
|
+
previousStatusLine,
|
|
711
|
+
bridgeStatusLine: nextStatusLine
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
return {
|
|
715
|
+
installed: true,
|
|
716
|
+
settingsPath,
|
|
717
|
+
bridgePath,
|
|
718
|
+
command,
|
|
719
|
+
previousStatusLine
|
|
720
|
+
};
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
export async function loadBridgeConfig(pluginData) {
|
|
724
|
+
return readJson(path.join(pluginData, 'statusline-bridge.json'), {});
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
async function runPreviousStatusLine(previousStatusLine, input) {
|
|
728
|
+
if (!previousStatusLine || previousStatusLine.type !== 'command') return '';
|
|
729
|
+
if (!previousStatusLine.command) return '';
|
|
730
|
+
|
|
731
|
+
return new Promise((resolve) => {
|
|
732
|
+
const child = spawn(previousStatusLine.command, {
|
|
733
|
+
shell: true,
|
|
734
|
+
windowsHide: true,
|
|
735
|
+
stdio: ['pipe', 'pipe', 'ignore']
|
|
736
|
+
});
|
|
737
|
+
let stdout = '';
|
|
738
|
+
const timer = setTimeout(() => {
|
|
739
|
+
child.kill();
|
|
740
|
+
resolve('');
|
|
741
|
+
}, 2500);
|
|
742
|
+
|
|
743
|
+
child.stdout.on('data', (chunk) => {
|
|
744
|
+
stdout += chunk;
|
|
745
|
+
if (stdout.length > 1024 * 1024) {
|
|
746
|
+
child.kill();
|
|
747
|
+
}
|
|
748
|
+
});
|
|
749
|
+
child.on('exit', () => {
|
|
750
|
+
clearTimeout(timer);
|
|
751
|
+
resolve(stdout.trim());
|
|
752
|
+
});
|
|
753
|
+
child.on('error', () => {
|
|
754
|
+
clearTimeout(timer);
|
|
755
|
+
resolve('');
|
|
756
|
+
});
|
|
757
|
+
child.stdin.end(JSON.stringify(input));
|
|
758
|
+
});
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
export async function renderStatusLine(input, {
|
|
762
|
+
pluginData = getDataDir(),
|
|
763
|
+
env = process.env
|
|
764
|
+
} = {}) {
|
|
765
|
+
const nextEnv = { ...env, CLAUDE_PLUGIN_DATA: pluginData };
|
|
766
|
+
await updateRateLimitFromStatusLine(input, nextEnv);
|
|
767
|
+
const bridge = await loadBridgeConfig(pluginData);
|
|
768
|
+
const previous = await runPreviousStatusLine(bridge.previousStatusLine, input);
|
|
769
|
+
const report = await buildReport(input?.session_id, nextEnv);
|
|
770
|
+
const fiveHour = report.state.rateLimits.fiveHour;
|
|
771
|
+
|
|
772
|
+
const guardSegment =
|
|
773
|
+
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)}%`;
|
|
776
|
+
|
|
777
|
+
return previous ? `${previous} | ${guardSegment}` : guardSegment;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
export async function pathExists(filePath) {
|
|
781
|
+
try {
|
|
782
|
+
await access(filePath, fsConstants.F_OK);
|
|
783
|
+
return true;
|
|
784
|
+
} catch {
|
|
785
|
+
return false;
|
|
786
|
+
}
|
|
787
|
+
}
|