claude-code-pulsify 1.2.1 → 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/bin/install.js +2 -18
- package/hooks/context-monitor.js +4 -22
- package/hooks/statusline.js +4 -74
- package/lib/context.js +31 -0
- package/lib/install.js +21 -0
- package/lib/statusline.js +83 -0
- package/package.json +6 -2
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,14 +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
|
-
|
|
125
55
|
async function main() {
|
|
126
56
|
let input = ''
|
|
127
57
|
await Promise.race([
|
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
|
}
|