claude-code-pulsify 1.2.0 → 1.3.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/README.md +13 -0
- package/bin/install.js +2 -18
- package/hooks/context-monitor.js +4 -22
- package/hooks/statusline.js +6 -100
- package/lib/context.js +31 -0
- package/lib/install.js +21 -0
- package/lib/statusline.js +83 -0
- package/package.json +6 -2
package/README.md
CHANGED
|
@@ -34,6 +34,14 @@ npx claude-code-pulsify@latest --uninstall
|
|
|
34
34
|
- **Context monitor** -- Warns at 65% and 75% context usage via PostToolUse hook
|
|
35
35
|
- **Update checker** -- Background version check on session start, non-blocking
|
|
36
36
|
|
|
37
|
+
## Context bar
|
|
38
|
+
|
|
39
|
+
The context bar shows **usable** context, not raw token count — it accounts for Claude's autocompact buffer (~16.5% of the window), which is reserved and unavailable to you.
|
|
40
|
+
|
|
41
|
+
- **0%** = fresh session
|
|
42
|
+
- **100%** = autocompact imminent (context will be compressed)
|
|
43
|
+
- Color thresholds: **green** (<50%) → **yellow** (50-65%) → **orange** (65-80%) → **red** (>80%)
|
|
44
|
+
|
|
37
45
|
## Checking your installed version
|
|
38
46
|
|
|
39
47
|
```bash
|
|
@@ -49,3 +57,8 @@ You can also check manually:
|
|
|
49
57
|
## Configuration
|
|
50
58
|
|
|
51
59
|
Respects the `CLAUDE_CONFIG_DIR` environment variable. Defaults to `~/.claude`.
|
|
60
|
+
|
|
61
|
+
## Future Enhancements
|
|
62
|
+
|
|
63
|
+
- Lines changed display (+added / -removed)
|
|
64
|
+
- Token usage counter
|
package/bin/install.js
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
const fs = require('node:fs')
|
|
5
5
|
const path = require('node:path')
|
|
6
6
|
|
|
7
|
+
const { isOurEntry, upsertHookArray, removeFromHookArray } = require('../lib/install')
|
|
8
|
+
|
|
7
9
|
const PACKAGE_VERSION = require('../package.json').version
|
|
8
10
|
const HOOKS_SOURCE = path.join(__dirname, '..', 'hooks')
|
|
9
11
|
const HOOK_FILES = ['statusline.js', 'context-monitor.js', 'check-update.js', 'check-update-worker.js']
|
|
@@ -33,24 +35,6 @@ function writeJSON(filePath, data) {
|
|
|
33
35
|
fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n', 'utf8')
|
|
34
36
|
}
|
|
35
37
|
|
|
36
|
-
function isOurEntry(entry) {
|
|
37
|
-
const cmd = entry?.hooks?.[0]?.command || ''
|
|
38
|
-
return cmd.includes('claude-code-pulsify')
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function upsertHookArray(arr, newEntry) {
|
|
42
|
-
const idx = arr.findIndex(isOurEntry)
|
|
43
|
-
if (idx >= 0) {
|
|
44
|
-
arr[idx] = newEntry
|
|
45
|
-
} else {
|
|
46
|
-
arr.push(newEntry)
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
function removeFromHookArray(arr) {
|
|
51
|
-
return arr.filter((entry) => !isOurEntry(entry))
|
|
52
|
-
}
|
|
53
|
-
|
|
54
38
|
// --- Install ---
|
|
55
39
|
|
|
56
40
|
function install() {
|
package/hooks/context-monitor.js
CHANGED
|
@@ -8,21 +8,9 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
const fs = require('node:fs')
|
|
11
|
-
const path = require('node:path')
|
|
12
11
|
|
|
13
|
-
|
|
14
|
-
const
|
|
15
|
-
const CRITICAL_THRESHOLD = 75 // remaining ~25% usable
|
|
16
|
-
|
|
17
|
-
// Debounce: minimum tool calls between repeated warnings at the same severity
|
|
18
|
-
const DEBOUNCE_COUNT = 5
|
|
19
|
-
|
|
20
|
-
// Bridge data older than this is considered stale and ignored
|
|
21
|
-
const BRIDGE_STALE_MS = 60_000
|
|
22
|
-
|
|
23
|
-
function sanitizeId(id) {
|
|
24
|
-
return id.replace(/[^a-zA-Z0-9_-]/g, '')
|
|
25
|
-
}
|
|
12
|
+
const { sanitizeId } = require('../lib/statusline')
|
|
13
|
+
const { BRIDGE_STALE_MS, getSeverity, shouldFireWarning } = require('../lib/context')
|
|
26
14
|
|
|
27
15
|
// State file to track debounce across invocations
|
|
28
16
|
function getStateFile(sessionId) {
|
|
@@ -78,20 +66,14 @@ async function main() {
|
|
|
78
66
|
if (usedPct == null) return
|
|
79
67
|
|
|
80
68
|
// Determine severity
|
|
81
|
-
|
|
82
|
-
if (usedPct >= CRITICAL_THRESHOLD) severity = 'CRITICAL'
|
|
83
|
-
else if (usedPct >= WARNING_THRESHOLD) severity = 'WARNING'
|
|
84
|
-
|
|
69
|
+
const severity = getSeverity(usedPct)
|
|
85
70
|
if (!severity) return // Context is fine
|
|
86
71
|
|
|
87
72
|
// Debounce logic
|
|
88
73
|
const state = readState(sessionId)
|
|
89
74
|
state.callsSinceWarning = (state.callsSinceWarning || 0) + 1
|
|
90
75
|
|
|
91
|
-
|
|
92
|
-
const shouldWarn = isEscalation || state.callsSinceWarning >= DEBOUNCE_COUNT
|
|
93
|
-
|
|
94
|
-
if (!shouldWarn) {
|
|
76
|
+
if (!shouldFireWarning(state.callsSinceWarning, severity, state.lastSeverity)) {
|
|
95
77
|
writeState(sessionId, state)
|
|
96
78
|
return
|
|
97
79
|
}
|
package/hooks/statusline.js
CHANGED
|
@@ -10,72 +10,10 @@
|
|
|
10
10
|
const fs = require('node:fs')
|
|
11
11
|
const path = require('node:path')
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
const DIM = `${ESC}2m`
|
|
18
|
-
const FG = (r, g, b) => `${ESC}38;2;${r};${g};${b}m`
|
|
19
|
-
|
|
20
|
-
// Colors
|
|
21
|
-
const GRAY = FG(100, 100, 100)
|
|
22
|
-
const WHITE = FG(200, 200, 200)
|
|
23
|
-
const GREEN = FG(80, 200, 80)
|
|
24
|
-
const YELLOW = FG(220, 200, 60)
|
|
25
|
-
const ORANGE = FG(230, 140, 40)
|
|
26
|
-
const RED = FG(220, 60, 60)
|
|
27
|
-
const CYAN = FG(80, 200, 220)
|
|
28
|
-
|
|
29
|
-
const SEPARATOR = `${GRAY}\u2502${RESET}`
|
|
30
|
-
|
|
31
|
-
const PROGRESS_BAR_SEGMENTS = 15
|
|
32
|
-
const MAX_TASK_LABEL_LENGTH = 40
|
|
33
|
-
|
|
34
|
-
// Claude's autocompact kicks in around 16.5% remaining context.
|
|
35
|
-
// We normalize the bar so 0% = empty, 100% = autocompact threshold,
|
|
36
|
-
// giving users a view of their *usable* context rather than total.
|
|
37
|
-
const AUTOCOMPACT_BUFFER = 16.5
|
|
38
|
-
|
|
39
|
-
function sanitizeId(id) {
|
|
40
|
-
return id.replace(/[^a-zA-Z0-9_-]/g, '')
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function normalizeUsage(remainingPct) {
|
|
44
|
-
// remaining_percentage goes from 100 (empty) to 0 (full)
|
|
45
|
-
// usable range is 100 down to ~16.5 (autocompact buffer)
|
|
46
|
-
const usableRange = 100 - AUTOCOMPACT_BUFFER
|
|
47
|
-
const usableRemaining = Math.max(0, remainingPct - AUTOCOMPACT_BUFFER)
|
|
48
|
-
const usedPct = ((usableRange - usableRemaining) / usableRange) * 100
|
|
49
|
-
return Math.min(100, Math.max(0, usedPct))
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function getBarColor(usedPct) {
|
|
53
|
-
if (usedPct < 50) return GREEN
|
|
54
|
-
if (usedPct < 65) return YELLOW
|
|
55
|
-
if (usedPct < 80) return ORANGE
|
|
56
|
-
return RED
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
function buildProgressBar(usedPct) {
|
|
60
|
-
const filled = Math.round((usedPct / 100) * PROGRESS_BAR_SEGMENTS)
|
|
61
|
-
const empty = PROGRESS_BAR_SEGMENTS - filled
|
|
62
|
-
const color = getBarColor(usedPct)
|
|
63
|
-
const emphasis = usedPct >= 80 ? BOLD : ''
|
|
64
|
-
|
|
65
|
-
const filledBar = `${emphasis}${color}${'█'.repeat(filled)}${RESET}`
|
|
66
|
-
const emptyBar = `${GRAY}${'░'.repeat(empty)}${RESET}`
|
|
67
|
-
const pctLabel = `${color}${BOLD}${Math.round(usedPct)}%${RESET}`
|
|
68
|
-
|
|
69
|
-
return `${filledBar}${emptyBar} ${pctLabel}`
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function getActiveTask(data) {
|
|
73
|
-
const todos = data.todos || []
|
|
74
|
-
const active = todos.find((t) => t.status === 'in_progress') || todos.find((t) => t.status === 'pending')
|
|
75
|
-
if (!active) return null
|
|
76
|
-
const label = active.content || active.description || ''
|
|
77
|
-
return label.length > MAX_TASK_LABEL_LENGTH ? label.slice(0, MAX_TASK_LABEL_LENGTH - 3) + '...' : label
|
|
78
|
-
}
|
|
13
|
+
const {
|
|
14
|
+
RESET, DIM, WHITE, CYAN, SEPARATOR,
|
|
15
|
+
sanitizeId, normalizeUsage, buildProgressBar, getActiveTask, formatCost,
|
|
16
|
+
} = require('../lib/statusline')
|
|
79
17
|
|
|
80
18
|
function getUpdateIndicator() {
|
|
81
19
|
const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(require('node:os').homedir(), '.claude')
|
|
@@ -114,30 +52,6 @@ function writeBridgeFile(sessionId, metrics) {
|
|
|
114
52
|
}
|
|
115
53
|
}
|
|
116
54
|
|
|
117
|
-
function formatCost(costUsd) {
|
|
118
|
-
if (!costUsd) return ''
|
|
119
|
-
if (costUsd < 1) return `${WHITE}$${costUsd.toFixed(2).replace(/0+$/, '').replace(/\.$/, '')}${RESET}`
|
|
120
|
-
const str = costUsd.toFixed(1).replace(/\.0$/, '')
|
|
121
|
-
return `${WHITE}$${str}${RESET}`
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
function formatLinesChanged(added, removed) {
|
|
125
|
-
const parts = []
|
|
126
|
-
if (added) parts.push(`${GREEN}+${added}${RESET}`)
|
|
127
|
-
if (removed) parts.push(`${RED}-${removed}${RESET}`)
|
|
128
|
-
return parts.join(' ')
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
function formatTokenCount(input, output) {
|
|
132
|
-
const total = (input || 0) + (output || 0)
|
|
133
|
-
if (!total) return ''
|
|
134
|
-
let compact
|
|
135
|
-
if (total >= 1e6) compact = `${(total / 1e6).toFixed(1)}M`
|
|
136
|
-
else if (total >= 1e3) compact = `${(total / 1e3).toFixed(1)}k`
|
|
137
|
-
else compact = `${total}`
|
|
138
|
-
return `${DIM}${compact} tok${RESET}`
|
|
139
|
-
}
|
|
140
|
-
|
|
141
55
|
async function main() {
|
|
142
56
|
let input = ''
|
|
143
57
|
await Promise.race([
|
|
@@ -179,10 +93,6 @@ async function main() {
|
|
|
179
93
|
const remainingPct = data.context_window?.remaining_percentage ?? 100
|
|
180
94
|
const sessionId = data.session?.id || data.session_id || null
|
|
181
95
|
const costUsd = data.cost?.total_cost_usd ?? 0
|
|
182
|
-
const linesAdded = data.cost?.total_lines_added ?? 0
|
|
183
|
-
const linesRemoved = data.cost?.total_lines_removed ?? 0
|
|
184
|
-
const totalInputTokens = data.context_window?.total_input_tokens ?? 0
|
|
185
|
-
const totalOutputTokens = data.context_window?.total_output_tokens ?? 0
|
|
186
96
|
|
|
187
97
|
// Normalize and build bar
|
|
188
98
|
const usedPct = normalizeUsage(remainingPct)
|
|
@@ -207,10 +117,8 @@ async function main() {
|
|
|
207
117
|
model,
|
|
208
118
|
})
|
|
209
119
|
|
|
210
|
-
// Format
|
|
120
|
+
// Format cost
|
|
211
121
|
const cost = formatCost(costUsd)
|
|
212
|
-
const lines = formatLinesChanged(linesAdded, linesRemoved)
|
|
213
|
-
const tokenCount = formatTokenCount(totalInputTokens, totalOutputTokens)
|
|
214
122
|
|
|
215
123
|
// Build segments array (only include non-empty optional segments)
|
|
216
124
|
const segments = [
|
|
@@ -218,9 +126,7 @@ async function main() {
|
|
|
218
126
|
dirLabel,
|
|
219
127
|
]
|
|
220
128
|
if (cost) segments.push(cost)
|
|
221
|
-
|
|
222
|
-
const barWithTokens = tokenCount ? `${bar} ${tokenCount}` : bar
|
|
223
|
-
segments.push(barWithTokens)
|
|
129
|
+
segments.push(bar)
|
|
224
130
|
|
|
225
131
|
// Output statusline
|
|
226
132
|
const line = segments.join(` ${SEPARATOR} `) + taskSegment + updateIndicator
|
package/lib/context.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Thresholds on "used percentage" (normalized, 0-100)
|
|
4
|
+
const WARNING_THRESHOLD = 65 // remaining ~35% usable
|
|
5
|
+
const CRITICAL_THRESHOLD = 75 // remaining ~25% usable
|
|
6
|
+
|
|
7
|
+
// Debounce: minimum tool calls between repeated warnings at the same severity
|
|
8
|
+
const DEBOUNCE_COUNT = 5
|
|
9
|
+
|
|
10
|
+
// Bridge data older than this is considered stale and ignored
|
|
11
|
+
const BRIDGE_STALE_MS = 60_000
|
|
12
|
+
|
|
13
|
+
function getSeverity(usedPct) {
|
|
14
|
+
if (usedPct >= CRITICAL_THRESHOLD) return 'CRITICAL'
|
|
15
|
+
if (usedPct >= WARNING_THRESHOLD) return 'WARNING'
|
|
16
|
+
return null
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function shouldFireWarning(callsSinceWarning, severity, lastSeverity) {
|
|
20
|
+
const isEscalation = severity === 'CRITICAL' && lastSeverity === 'WARNING'
|
|
21
|
+
return isEscalation || callsSinceWarning >= DEBOUNCE_COUNT
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
module.exports = {
|
|
25
|
+
WARNING_THRESHOLD,
|
|
26
|
+
CRITICAL_THRESHOLD,
|
|
27
|
+
DEBOUNCE_COUNT,
|
|
28
|
+
BRIDGE_STALE_MS,
|
|
29
|
+
getSeverity,
|
|
30
|
+
shouldFireWarning,
|
|
31
|
+
}
|
package/lib/install.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
function isOurEntry(entry) {
|
|
4
|
+
const cmd = entry?.hooks?.[0]?.command || ''
|
|
5
|
+
return cmd.includes('claude-code-pulsify')
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function upsertHookArray(arr, newEntry) {
|
|
9
|
+
const idx = arr.findIndex(isOurEntry)
|
|
10
|
+
if (idx >= 0) {
|
|
11
|
+
arr[idx] = newEntry
|
|
12
|
+
} else {
|
|
13
|
+
arr.push(newEntry)
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function removeFromHookArray(arr) {
|
|
18
|
+
return arr.filter((entry) => !isOurEntry(entry))
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
module.exports = { isOurEntry, upsertHookArray, removeFromHookArray }
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// ANSI helpers
|
|
4
|
+
const ESC = '\x1b['
|
|
5
|
+
const RESET = `${ESC}0m`
|
|
6
|
+
const BOLD = `${ESC}1m`
|
|
7
|
+
const DIM = `${ESC}2m`
|
|
8
|
+
const FG = (r, g, b) => `${ESC}38;2;${r};${g};${b}m`
|
|
9
|
+
|
|
10
|
+
// Colors
|
|
11
|
+
const GRAY = FG(100, 100, 100)
|
|
12
|
+
const WHITE = FG(200, 200, 200)
|
|
13
|
+
const GREEN = FG(80, 200, 80)
|
|
14
|
+
const YELLOW = FG(220, 200, 60)
|
|
15
|
+
const ORANGE = FG(230, 140, 40)
|
|
16
|
+
const RED = FG(220, 60, 60)
|
|
17
|
+
const CYAN = FG(80, 200, 220)
|
|
18
|
+
|
|
19
|
+
const SEPARATOR = `${GRAY}\u2502${RESET}`
|
|
20
|
+
|
|
21
|
+
const PROGRESS_BAR_SEGMENTS = 15
|
|
22
|
+
const MAX_TASK_LABEL_LENGTH = 40
|
|
23
|
+
|
|
24
|
+
// Claude's autocompact kicks in around 16.5% remaining context.
|
|
25
|
+
// We normalize the bar so 0% = empty, 100% = autocompact threshold,
|
|
26
|
+
// giving users a view of their *usable* context rather than total.
|
|
27
|
+
const AUTOCOMPACT_BUFFER = 16.5
|
|
28
|
+
|
|
29
|
+
function sanitizeId(id) {
|
|
30
|
+
return id.replace(/[^a-zA-Z0-9_-]/g, '')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function normalizeUsage(remainingPct) {
|
|
34
|
+
// remaining_percentage goes from 100 (empty) to 0 (full)
|
|
35
|
+
// usable range is 100 down to ~16.5 (autocompact buffer)
|
|
36
|
+
const usableRange = 100 - AUTOCOMPACT_BUFFER
|
|
37
|
+
const usableRemaining = Math.max(0, remainingPct - AUTOCOMPACT_BUFFER)
|
|
38
|
+
const usedPct = ((usableRange - usableRemaining) / usableRange) * 100
|
|
39
|
+
return Math.min(100, Math.max(0, usedPct))
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function getBarColor(usedPct) {
|
|
43
|
+
if (usedPct < 50) return GREEN
|
|
44
|
+
if (usedPct < 65) return YELLOW
|
|
45
|
+
if (usedPct < 80) return ORANGE
|
|
46
|
+
return RED
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function buildProgressBar(usedPct) {
|
|
50
|
+
const filled = Math.round((usedPct / 100) * PROGRESS_BAR_SEGMENTS)
|
|
51
|
+
const empty = PROGRESS_BAR_SEGMENTS - filled
|
|
52
|
+
const color = getBarColor(usedPct)
|
|
53
|
+
const emphasis = usedPct >= 80 ? BOLD : ''
|
|
54
|
+
|
|
55
|
+
const filledBar = `${emphasis}${color}${'█'.repeat(filled)}${RESET}`
|
|
56
|
+
const emptyBar = `${GRAY}${'░'.repeat(empty)}${RESET}`
|
|
57
|
+
const pctLabel = `${color}${BOLD}${Math.round(usedPct)}%${RESET}`
|
|
58
|
+
|
|
59
|
+
return `${filledBar}${emptyBar} ${pctLabel}`
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function getActiveTask(data) {
|
|
63
|
+
const todos = data.todos || []
|
|
64
|
+
const active = todos.find((t) => t.status === 'in_progress') || todos.find((t) => t.status === 'pending')
|
|
65
|
+
if (!active) return null
|
|
66
|
+
const label = active.content || active.description || ''
|
|
67
|
+
return label.length > MAX_TASK_LABEL_LENGTH ? label.slice(0, MAX_TASK_LABEL_LENGTH - 3) + '...' : label
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function formatCost(costUsd) {
|
|
71
|
+
if (!costUsd) return ''
|
|
72
|
+
if (costUsd < 1) return `${WHITE}$${costUsd.toFixed(2).replace(/0+$/, '').replace(/\.$/, '')}${RESET}`
|
|
73
|
+
const str = costUsd.toFixed(1).replace(/\.0$/, '')
|
|
74
|
+
return `${WHITE}$${str}${RESET}`
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
module.exports = {
|
|
78
|
+
// ANSI helpers
|
|
79
|
+
RESET, DIM, WHITE, CYAN,
|
|
80
|
+
SEPARATOR,
|
|
81
|
+
// Functions
|
|
82
|
+
sanitizeId, normalizeUsage, getBarColor, buildProgressBar, getActiveTask, formatCost,
|
|
83
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-code-pulsify",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "Context-aware statusline and context monitor for Claude Code",
|
|
5
5
|
"bin": {
|
|
6
6
|
"claude-code-pulsify": "bin/install.js"
|
|
@@ -12,12 +12,16 @@
|
|
|
12
12
|
"engines": {
|
|
13
13
|
"node": ">=20"
|
|
14
14
|
},
|
|
15
|
+
"scripts": {
|
|
16
|
+
"test": "node --test 'tests/*.test.js'"
|
|
17
|
+
},
|
|
15
18
|
"license": "MIT",
|
|
16
19
|
"publishConfig": {
|
|
17
20
|
"access": "public"
|
|
18
21
|
},
|
|
19
22
|
"files": [
|
|
20
23
|
"bin/",
|
|
21
|
-
"hooks/"
|
|
24
|
+
"hooks/",
|
|
25
|
+
"lib/"
|
|
22
26
|
]
|
|
23
27
|
}
|