@rex_koh/subagent-budget-guard 0.5.1 → 0.5.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-cap",
3
3
  "displayName": "Subagent Cap",
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.5.1",
5
+ "version": "0.5.3",
6
6
  "author": {
7
7
  "name": "ClaudeSubAgentSuppressor"
8
8
  },
package/README.md CHANGED
@@ -15,7 +15,7 @@ Recommended Claude Code install:
15
15
 
16
16
  After `/subagent-cap:init`, fully exit and reopen Claude Code so the statusLine bridge from `settings.json` is active. Some Claude Code builds do not provide an in-session plugin reload command.
17
17
 
18
- `/sub-agent-view` can be run after a session to display how many subagents were spawned, the verified token total, total duration, and each saved subagent run with its token count, duration, model, and tool-call count.
18
+ `/sub-agent-view` can be run after a session to display how many subagents were spawned, queued subagents waiting for retry, the verified token total, total duration, and each saved subagent run with its token count, duration, model, and tool-call count.
19
19
 
20
20
  ## NPM Package
21
21
 
@@ -30,7 +30,9 @@ subagent-cap status
30
30
  sub-agent-view
31
31
  ```
32
32
 
33
- `sub-agent-view` prints the latest session's recorded subagents with per-subagent status, type, description, verified token count, duration, model, and tool-call count. Use `sub-agent-view --session <session-id>` for a specific saved session, or `sub-agent-view --json` for machine-readable output. The same view is also available as the Claude command `/sub-agent-view` and the npm alias `subagent-cap view`.
33
+ `sub-agent-view` prints the latest session's recorded subagents with per-subagent status, type, description, verified token count, duration, model, tool-call count, and queued retry items. Use `sub-agent-view --session <session-id>` for a specific saved session, or `sub-agent-view --json` for machine-readable output. The same view is also available as the Claude command `/sub-agent-view` and the npm alias `subagent-cap view`.
34
+
35
+ When an `Agent` launch fails only because `max_concurrent_subagents` is already reached, the plugin stores that subagent in a local retry queue with the full original prompt. The default text view does not print full queued prompts. Once active subagents drop below the cap, the plugin injects a reminder for the highest-priority queued item after a tool batch or on the next user prompt so Claude can retry it before lower-priority new work. Hooks cannot autonomously launch a subagent after `SubagentStop`; the queue is surfaced as context for Claude's next action.
34
36
 
35
37
  Maintainer publish command:
36
38
 
package/bin/hook.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
+ handlePostToolBatch,
3
4
  handlePostToolUseAgent,
4
5
  handlePreToolUseAgent,
5
6
  handleSubagentStart,
@@ -15,7 +16,7 @@ async function readStdin() {
15
16
  for await (const chunk of process.stdin) {
16
17
  input += chunk;
17
18
  }
18
- return input ? JSON.parse(input) : {};
19
+ return input ? JSON.parse(input.replace(/^\uFEFF/, '')) : {};
19
20
  }
20
21
 
21
22
  function emit(result) {
@@ -30,6 +31,7 @@ function emit(result) {
30
31
 
31
32
  const handlers = {
32
33
  'pretool-agent': handlePreToolUseAgent,
34
+ 'posttool-batch': handlePostToolBatch,
33
35
  'posttool-agent': handlePostToolUseAgent,
34
36
  'subagent-start': handleSubagentStart,
35
37
  'subagent-stop': handleSubagentStop,
package/bin/statusline.js CHANGED
@@ -13,7 +13,7 @@ async function readStdin() {
13
13
  for await (const chunk of process.stdin) {
14
14
  input += chunk;
15
15
  }
16
- return input ? JSON.parse(input) : {};
16
+ return input ? JSON.parse(input.replace(/^\uFEFF/, '')) : {};
17
17
  }
18
18
 
19
19
  async function main() {
package/bin/view.js CHANGED
@@ -17,6 +17,7 @@ async function main() {
17
17
  spawnedSubagents: report.state.subagents.runs.length,
18
18
  verifiedTokens: report.state.subagents.verifiedTokens,
19
19
  totalDurationMs: report.state.subagents.totalDurationMs,
20
+ queuedSubagents: report.state.subagents.queue || [],
20
21
  subagents: report.state.subagents.runs
21
22
  };
22
23
 
@@ -1,17 +1,14 @@
1
1
  ---
2
2
  description: Show recorded subagent count, verified tokens, and duration for saved sessions.
3
3
  argument-hint: "[--session <session-id>] [--json]"
4
+ allowed-tools: Bash(node:*)
4
5
  disable-model-invocation: true
5
6
  ---
6
7
 
7
8
  # View Recorded Subagents
8
9
 
9
- Run this command:
10
+ The saved subagent view is:
10
11
 
11
- ```bash
12
- node "${CLAUDE_PLUGIN_ROOT}/bin/view.js" $ARGUMENTS
13
- ```
14
-
15
- Show the command output verbatim.
12
+ !`node "${CLAUDE_PLUGIN_ROOT}/bin/view.js" $ARGUMENTS`
16
13
 
17
14
  If the command fails, show the error output and say that `/subagent-cap:init` must be run before saved session data is available.
package/hooks/hooks.json CHANGED
@@ -34,6 +34,22 @@
34
34
  ]
35
35
  }
36
36
  ],
37
+ "PostToolBatch": [
38
+ {
39
+ "hooks": [
40
+ {
41
+ "type": "command",
42
+ "command": "node",
43
+ "args": [
44
+ "${CLAUDE_PLUGIN_ROOT}/bin/hook.js",
45
+ "posttool-batch"
46
+ ],
47
+ "timeout": 10,
48
+ "statusMessage": "Checking queued subagents"
49
+ }
50
+ ]
51
+ }
52
+ ],
37
53
  "SubagentStart": [
38
54
  {
39
55
  "matcher": "",
package/lib/guard.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { spawn } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
2
3
  import { constants as fsConstants, readFileSync } from 'node:fs';
3
4
  import {
4
5
  access,
@@ -186,6 +187,10 @@ function initialState(sessionId) {
186
187
  tokenBudgetWarnings: 0,
187
188
  tokenBudgetExceeded: false,
188
189
  lastTokenBudgetNoticeAt: null,
190
+ queued: 0,
191
+ queueLaunched: 0,
192
+ queueNotices: 0,
193
+ queue: [],
189
194
  runs: []
190
195
  },
191
196
  agentTeam: {
@@ -379,6 +384,196 @@ function formatCount(value) {
379
384
  return Number(value || 0).toLocaleString('en-US');
380
385
  }
381
386
 
387
+ function normalizeText(value) {
388
+ return String(value || '').trim();
389
+ }
390
+
391
+ function agentIdentity(input) {
392
+ const toolInput = input?.tool_input || {};
393
+ return {
394
+ description: normalizeText(toolInput.description),
395
+ subagentType: normalizeText(toolInput.subagent_type),
396
+ prompt: normalizeText(toolInput.prompt)
397
+ };
398
+ }
399
+
400
+ function agentFingerprint(input) {
401
+ const identity = agentIdentity(input);
402
+ return createHash('sha256')
403
+ .update(JSON.stringify(identity))
404
+ .digest('hex');
405
+ }
406
+
407
+ function agentQueuePriority(input) {
408
+ const identity = agentIdentity(input);
409
+ const text = `${identity.description} ${identity.subagentType} ${identity.prompt}`.toLowerCase();
410
+
411
+ if (/(urgent|critical|blocker|high[- ]priority|priority|asap|production)/.test(text)) {
412
+ return 100;
413
+ }
414
+
415
+ if (/(security|auth|bug|failure|failing|fix|test|review)/.test(text)) {
416
+ return 50;
417
+ }
418
+
419
+ return 0;
420
+ }
421
+
422
+ function queuedAgentSummary(item) {
423
+ const type = item.subagentType || 'unknown';
424
+ const description = item.description || 'no description';
425
+ return `${type} "${description}"`;
426
+ }
427
+
428
+ function findQueuedAgentIndex(state, fingerprint) {
429
+ return state.subagents.queue.findIndex((item) => item.fingerprint === fingerprint);
430
+ }
431
+
432
+ function compareQueuedAgents(a, b) {
433
+ const priorityDiff = Number(b.priority || 0) - Number(a.priority || 0);
434
+ if (priorityDiff !== 0) return priorityDiff;
435
+ return String(a.queuedAt || '').localeCompare(String(b.queuedAt || ''));
436
+ }
437
+
438
+ function sortQueuedAgents(state) {
439
+ state.subagents.queue.sort(compareQueuedAgents);
440
+ }
441
+
442
+ function queueConcurrencyDeniedAgent(state, input, reason) {
443
+ const fingerprint = agentFingerprint(input);
444
+ const existingIndex = findQueuedAgentIndex(state, fingerprint);
445
+ const identity = agentIdentity(input);
446
+
447
+ state.subagents.queued += 1;
448
+
449
+ if (existingIndex !== -1) {
450
+ const existing = state.subagents.queue[existingIndex];
451
+ existing.attempts += 1;
452
+ existing.lastQueuedAt = nowIso();
453
+ existing.priority = Math.max(existing.priority || 0, agentQueuePriority(input));
454
+ existing.reason = reason;
455
+ sortQueuedAgents(state);
456
+ pushEvent(state, {
457
+ type: 'agent-queue-duplicate',
458
+ queueId: existing.queueId,
459
+ attempts: existing.attempts,
460
+ reason
461
+ });
462
+ return existing;
463
+ }
464
+
465
+ const queueId = `queue-${fingerprint.slice(0, 12)}`;
466
+ const item = {
467
+ queueId,
468
+ fingerprint,
469
+ status: 'queued',
470
+ priority: agentQueuePriority(input),
471
+ attempts: 1,
472
+ queuedAt: nowIso(),
473
+ lastQueuedAt: nowIso(),
474
+ lastNotifiedAt: null,
475
+ notifyCount: 0,
476
+ reason,
477
+ description: identity.description || null,
478
+ subagentType: identity.subagentType || null,
479
+ prompt: identity.prompt || null
480
+ };
481
+
482
+ state.subagents.queue.push(item);
483
+ sortQueuedAgents(state);
484
+ pushEvent(state, {
485
+ type: 'agent-queued',
486
+ queueId,
487
+ priority: item.priority,
488
+ reason,
489
+ description: item.description,
490
+ subagentType: item.subagentType
491
+ });
492
+ return item;
493
+ }
494
+
495
+ function removeMatchingQueuedAgent(state, input) {
496
+ const index = findQueuedAgentIndex(state, agentFingerprint(input));
497
+ if (index === -1) return null;
498
+
499
+ const [item] = state.subagents.queue.splice(index, 1);
500
+ state.subagents.queueLaunched += 1;
501
+ pushEvent(state, {
502
+ type: 'agent-queue-launched',
503
+ queueId: item.queueId,
504
+ description: item.description,
505
+ subagentType: item.subagentType
506
+ });
507
+ return item;
508
+ }
509
+
510
+ function nextQueuedAgent(state) {
511
+ const queued = [...state.subagents.queue].filter((item) => item.status === 'queued');
512
+ queued.sort(compareQueuedAgents);
513
+ return queued[0] || null;
514
+ }
515
+
516
+ function canRetryQueuedAgent(state, config) {
517
+ return (
518
+ config.enforcement_enabled &&
519
+ config.max_concurrent_subagents > 0 &&
520
+ state.subagents.active < config.max_concurrent_subagents &&
521
+ state.subagents.queue.length > 0
522
+ );
523
+ }
524
+
525
+ function formatQueuedAgentContext(item, state, config) {
526
+ const available = Math.max(0, config.max_concurrent_subagents - state.subagents.active);
527
+ return [
528
+ 'Queued subagent ready to retry.',
529
+ `Queue id: ${item.queueId}`,
530
+ `Priority: ${Number(item.priority || 0)}`,
531
+ `Attempts: ${Number(item.attempts || 0)}`,
532
+ `Concurrency available: ${available}/${config.max_concurrent_subagents}`,
533
+ `Subagent type: ${item.subagentType || 'unknown'}`,
534
+ `Description: ${item.description || 'no description'}`,
535
+ 'Retry this queued Agent task before starting new lower-priority subagent work.',
536
+ 'Use the full original prompt below when retrying:',
537
+ item.prompt || '(empty prompt)'
538
+ ].join('\n');
539
+ }
540
+
541
+ async function buildQueuedAgentNotice(sessionId, env, hookEventName) {
542
+ const config = loadConfig(env);
543
+ let context = null;
544
+
545
+ await updateState(sessionId, env, (state) => {
546
+ if (!canRetryQueuedAgent(state, config)) return state;
547
+
548
+ const item = nextQueuedAgent(state);
549
+ if (!item) return state;
550
+
551
+ item.notifyCount = Number(item.notifyCount || 0) + 1;
552
+ item.lastNotifiedAt = nowIso();
553
+ state.subagents.queueNotices += 1;
554
+ context = formatQueuedAgentContext(item, state, config);
555
+ pushEvent(state, {
556
+ type: 'agent-queue-notice',
557
+ queueId: item.queueId,
558
+ hookEventName,
559
+ notifyCount: item.notifyCount
560
+ });
561
+ return state;
562
+ });
563
+
564
+ if (!context) return null;
565
+ return {
566
+ exitCode: 0,
567
+ stdout: {
568
+ hookSpecificOutput: {
569
+ hookEventName,
570
+ additionalContext: context
571
+ }
572
+ },
573
+ stderr: ''
574
+ };
575
+ }
576
+
382
577
  function subagentTokenBudgetStatus(state, config) {
383
578
  const limit = config.max_subagent_tokens_per_session;
384
579
  if (!limit || limit <= 0) return null;
@@ -422,21 +617,31 @@ function subagentTokenBudgetDecision(state, config, { includeWarning = true } =
422
617
  return null;
423
618
  }
424
619
 
425
- function agentDenyReason(state, config) {
620
+ function agentDenyDecision(state, config) {
426
621
  if (!config.enforcement_enabled) return null;
427
622
 
428
623
  const budgetReason = fiveHourBudgetDecision(state, config);
429
- if (budgetReason) return budgetReason;
624
+ if (budgetReason) {
625
+ return { reason: budgetReason, queueable: false };
626
+ }
430
627
 
431
628
  const tokenBudgetReason = subagentTokenBudgetDecision(state, config);
432
- if (tokenBudgetReason) return tokenBudgetReason.reason;
629
+ if (tokenBudgetReason) {
630
+ return { reason: tokenBudgetReason.reason, queueable: false };
631
+ }
433
632
 
434
633
  if (config.max_concurrent_subagents === 0) {
435
- return 'Subagent launch denied: max_concurrent_subagents is 0.';
634
+ return {
635
+ reason: 'Subagent launch denied: max_concurrent_subagents is 0.',
636
+ queueable: false
637
+ };
436
638
  }
437
639
 
438
640
  if (state.subagents.active >= config.max_concurrent_subagents) {
439
- return `Subagent launch denied: max_concurrent_subagents ${config.max_concurrent_subagents} already reached.`;
641
+ return {
642
+ reason: `Subagent launch queued: max_concurrent_subagents ${config.max_concurrent_subagents} already reached. Retry this queued agent when active subagents drop below the cap.`,
643
+ queueable: true
644
+ };
440
645
  }
441
646
 
442
647
  return null;
@@ -446,12 +651,18 @@ export async function handlePreToolUseAgent(input, env = process.env) {
446
651
  const sessionId = input?.session_id || 'unknown-session';
447
652
  const config = loadConfig(env);
448
653
  let reason = null;
654
+ let queuedItem = null;
449
655
 
450
656
  await updateState(sessionId, env, (state) => {
451
657
  state.subagents.requested += 1;
452
- reason = agentDenyReason(state, config);
453
- if (reason) {
658
+ const decision = agentDenyDecision(state, config);
659
+ reason = decision?.reason || null;
660
+ if (decision) {
454
661
  state.subagents.denied += 1;
662
+ if (decision.queueable) {
663
+ queuedItem = queueConcurrencyDeniedAgent(state, input, reason);
664
+ reason = `${reason} Queue id: ${queuedItem.queueId}.`;
665
+ }
455
666
  pushEvent(state, {
456
667
  type: 'agent-denied',
457
668
  reason,
@@ -459,9 +670,11 @@ export async function handlePreToolUseAgent(input, env = process.env) {
459
670
  subagentType: input?.tool_input?.subagent_type || null
460
671
  });
461
672
  } else {
673
+ const launchedQueuedItem = removeMatchingQueuedAgent(state, input);
462
674
  state.subagents.allowed += 1;
463
675
  pushEvent(state, {
464
676
  type: 'agent-allowed',
677
+ queueId: launchedQueuedItem?.queueId || null,
465
678
  description: input?.tool_input?.description || null,
466
679
  subagentType: input?.tool_input?.subagent_type || null
467
680
  });
@@ -569,6 +782,13 @@ export async function handlePostToolUseAgent(input, env = process.env) {
569
782
  return { exitCode: 0, stdout: null, stderr: '' };
570
783
  }
571
784
 
785
+ export async function handlePostToolBatch(input, env = process.env) {
786
+ const sessionId = input?.session_id || 'unknown-session';
787
+ const notice = await buildQueuedAgentNotice(sessionId, env, 'PostToolBatch');
788
+
789
+ return notice || { exitCode: 0, stdout: null, stderr: '' };
790
+ }
791
+
572
792
  export async function handleSubagentStart(input, env = process.env) {
573
793
  const sessionId = input?.session_id || 'unknown-session';
574
794
  await updateState(sessionId, env, (state) => {
@@ -676,7 +896,8 @@ export async function handleUserPromptSubmit(input, env = process.env) {
676
896
  const reason = fiveHourBudgetDecision(state, config);
677
897
 
678
898
  if (!reason) {
679
- return { exitCode: 0, stdout: null, stderr: '' };
899
+ const notice = await buildQueuedAgentNotice(sessionId, env, 'UserPromptSubmit');
900
+ return notice || { exitCode: 0, stdout: null, stderr: '' };
680
901
  }
681
902
 
682
903
  await updateState(sessionId, env, (nextState) => {
@@ -788,14 +1009,16 @@ function formatDuration(ms) {
788
1009
 
789
1010
  export function formatSubagentView(report) {
790
1011
  const runs = report.state.subagents.runs;
1012
+ const queued = report.state.subagents.queue || [];
791
1013
  const lines = [
792
1014
  `Sub-agent view for ${report.sessionId}`,
793
1015
  `Spawned subagents: ${runs.length}`,
1016
+ `Queued subagents: ${queued.length}`,
794
1017
  `Verified tokens: ${formatCount(report.state.subagents.verifiedTokens)}`,
795
1018
  `Total duration: ${formatDuration(report.state.subagents.totalDurationMs)}`
796
1019
  ];
797
1020
 
798
- if (runs.length === 0) {
1021
+ if (runs.length === 0 && queued.length === 0) {
799
1022
  lines.push('No subagents recorded for this session.');
800
1023
  return lines.join('\n');
801
1024
  }
@@ -810,6 +1033,15 @@ export function formatSubagentView(report) {
810
1033
  lines.push(` tools: ${Number(run.totalToolUseCount || 0)}`);
811
1034
  }
812
1035
 
1036
+ if (queued.length > 0) {
1037
+ lines.push('Queued:');
1038
+ for (const item of queued) {
1039
+ lines.push(`- ${item.queueId} ${queuedAgentSummary(item)}`);
1040
+ lines.push(` priority: ${Number(item.priority || 0)}, attempts: ${Number(item.attempts || 0)}`);
1041
+ lines.push(` queued_at: ${item.queuedAt || 'unknown'}`);
1042
+ }
1043
+ }
1044
+
813
1045
  return lines.join('\n');
814
1046
  }
815
1047
 
package/lib/verifier.js CHANGED
@@ -111,7 +111,7 @@ export async function runOfflineVerification({
111
111
  entry.source?.package === '@rex_koh/subagent-budget-guard',
112
112
  'marketplace npm package mismatch'
113
113
  );
114
- assert(entry.source?.version === '0.5.1', 'marketplace npm version mismatch');
114
+ assert(entry.source?.version === '0.5.3', 'marketplace npm version mismatch');
115
115
  return marketplacePath;
116
116
  });
117
117
  } else {
@@ -151,6 +151,7 @@ export async function runOfflineVerification({
151
151
  const requiredEvents = [
152
152
  'PreToolUse',
153
153
  'PostToolUse',
154
+ 'PostToolBatch',
154
155
  'SubagentStart',
155
156
  'SubagentStop',
156
157
  'TaskCreated',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rex_koh/subagent-budget-guard",
3
- "version": "0.5.1",
3
+ "version": "0.5.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",