claude-code-pulsify 1.0.2 → 1.1.1

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 CHANGED
@@ -34,6 +34,18 @@ 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
+ ## Checking your installed version
38
+
39
+ ```bash
40
+ npx claude-code-pulsify --status
41
+ ```
42
+
43
+ This shows the currently installed version, hooks location, and whether an update is available.
44
+
45
+ You can also check manually:
46
+ - **VERSION file:** `~/.claude/hooks/claude-code-pulsify/VERSION`
47
+ - **Update cache:** `~/.claude/cache/claude-code-pulsify-update.json` — written on each session start, contains `installed`, `latest`, and `updateAvailable` fields
48
+
37
49
  ## Configuration
38
50
 
39
51
  Respects the `CLAUDE_CONFIG_DIR` environment variable. Defaults to `~/.claude`.
package/bin/install.js CHANGED
@@ -1,14 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict'
3
3
 
4
- const fs = require('fs')
5
- const path = require('path')
4
+ const fs = require('node:fs')
5
+ const path = require('node:path')
6
6
 
7
7
  const PACKAGE_VERSION = require('../package.json').version
8
8
  const HOOKS_SOURCE = path.join(__dirname, '..', 'hooks')
9
- const HOOK_FILES = ['statusline.js', 'context-monitor.js', 'check-update.js']
9
+ const HOOK_FILES = ['statusline.js', 'context-monitor.js', 'check-update.js', 'check-update-worker.js']
10
10
 
11
- const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(require('os').homedir(), '.claude')
11
+ const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(require('node:os').homedir(), '.claude')
12
12
  const hooksTarget = path.join(configDir, 'hooks', 'claude-code-pulsify')
13
13
  const settingsPath = path.join(configDir, 'settings.json')
14
14
 
@@ -17,8 +17,15 @@ const settingsPath = path.join(configDir, 'settings.json')
17
17
  function readJSON(filePath) {
18
18
  try {
19
19
  return JSON.parse(fs.readFileSync(filePath, 'utf8'))
20
- } catch {
21
- return {}
20
+ } catch (err) {
21
+ if (err.code === 'ENOENT') return {}
22
+ if (err instanceof SyntaxError) {
23
+ const backupPath = `${filePath}.backup-${Date.now()}`
24
+ console.warn(` ⚠ Parse error in ${filePath} — backing up to ${backupPath}`)
25
+ fs.copyFileSync(filePath, backupPath)
26
+ return {}
27
+ }
28
+ throw err
22
29
  }
23
30
  }
24
31
 
@@ -38,7 +45,6 @@ function upsertHookArray(arr, newEntry) {
38
45
  } else {
39
46
  arr.push(newEntry)
40
47
  }
41
- return arr
42
48
  }
43
49
 
44
50
  function removeFromHookArray(arr) {
@@ -63,7 +69,17 @@ function install() {
63
69
  fs.writeFileSync(path.join(hooksTarget, 'VERSION'), PACKAGE_VERSION, 'utf8')
64
70
  console.log(` Wrote VERSION (${PACKAGE_VERSION})`)
65
71
 
66
- // 3. Patch settings.json
72
+ // 3. Clear update cache so stale "update available" indicators don't persist after install.
73
+ // The background worker will refresh it on next session.
74
+ const cachePath = path.join(configDir, 'cache', 'claude-code-pulsify-update.json')
75
+ try {
76
+ fs.unlinkSync(cachePath)
77
+ console.log(` Cleared update cache`)
78
+ } catch {
79
+ // Doesn't exist — fine
80
+ }
81
+
82
+ // 4. Patch settings.json
67
83
  const settings = readJSON(settingsPath)
68
84
 
69
85
  // statusLine
@@ -160,6 +176,26 @@ const args = process.argv.slice(2)
160
176
 
161
177
  if (args.includes('--version')) {
162
178
  console.log(PACKAGE_VERSION)
179
+ } else if (args.includes('--status')) {
180
+ const versionFile = path.join(hooksTarget, 'VERSION')
181
+ const cachePath = path.join(configDir, 'cache', 'claude-code-pulsify-update.json')
182
+ const installed = fs.existsSync(versionFile) ? fs.readFileSync(versionFile, 'utf8').trim() : null
183
+ if (!installed) {
184
+ console.log('claude-code-pulsify is not installed.')
185
+ process.exit(1)
186
+ }
187
+ console.log(`Installed: v${installed}`)
188
+ console.log(`Hooks: ${hooksTarget}`)
189
+ try {
190
+ const cache = JSON.parse(fs.readFileSync(cachePath, 'utf8'))
191
+ if (cache.updateAvailable && cache.latest) {
192
+ console.log(`Latest: v${cache.latest} (update available)`)
193
+ } else {
194
+ console.log(`Latest: v${cache.latest || installed} (up to date)`)
195
+ }
196
+ } catch {
197
+ console.log(`Latest: unknown (run a Claude Code session to check)`)
198
+ }
163
199
  } else if (args.includes('--uninstall')) {
164
200
  uninstall()
165
201
  } else {
@@ -0,0 +1,45 @@
1
+ #!/usr/bin/env node
2
+ 'use strict'
3
+
4
+ /**
5
+ * Background worker for checking npm updates.
6
+ * Invoked as a detached process by check-update.js.
7
+ *
8
+ * Expected environment variables:
9
+ * PULSIFY_CACHE_PATH — path to write the update cache JSON
10
+ * PULSIFY_CACHE_DIR — parent directory (created if missing)
11
+ * PULSIFY_INSTALLED — currently installed version string
12
+ */
13
+
14
+ const { execSync } = require('node:child_process')
15
+ const fs = require('node:fs')
16
+
17
+ const NPM_VIEW_TIMEOUT_MS = 10000
18
+
19
+ const cachePath = process.env.PULSIFY_CACHE_PATH
20
+ const cacheDir = process.env.PULSIFY_CACHE_DIR
21
+ const installed = process.env.PULSIFY_INSTALLED
22
+
23
+ if (!cachePath || !cacheDir || !installed) {
24
+ process.exit(1)
25
+ }
26
+
27
+ try {
28
+ const latest = execSync('npm view claude-code-pulsify version', {
29
+ timeout: NPM_VIEW_TIMEOUT_MS,
30
+ encoding: 'utf8',
31
+ stdio: ['pipe', 'pipe', 'pipe'],
32
+ }).trim()
33
+ fs.mkdirSync(cacheDir, { recursive: true })
34
+ fs.writeFileSync(
35
+ cachePath,
36
+ JSON.stringify({
37
+ installed,
38
+ latest,
39
+ updateAvailable: latest.localeCompare(installed, undefined, { numeric: true }) > 0,
40
+ checkedAt: Date.now(),
41
+ }),
42
+ )
43
+ } catch {
44
+ // Network error or npm not available — silently ignore
45
+ }
@@ -3,15 +3,15 @@
3
3
 
4
4
  /**
5
5
  * SessionStart hook -- background version check for claude-code-pulsify.
6
- * Spawns a detached process to compare installed version vs npm latest,
6
+ * Spawns a detached worker process to compare installed version vs npm latest,
7
7
  * writes result to a cache file that statusline.js reads.
8
8
  */
9
9
 
10
- const { spawn } = require('child_process')
11
- const fs = require('fs')
12
- const path = require('path')
10
+ const { spawn } = require('node:child_process')
11
+ const fs = require('node:fs')
12
+ const path = require('node:path')
13
13
 
14
- const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(require('os').homedir(), '.claude')
14
+ const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(require('node:os').homedir(), '.claude')
15
15
  const hooksDir = path.join(configDir, 'hooks', 'claude-code-pulsify')
16
16
  const cacheDir = path.join(configDir, 'cache')
17
17
  const cachePath = path.join(cacheDir, 'claude-code-pulsify-update.json')
@@ -32,36 +32,24 @@ async function main() {
32
32
  return // No VERSION file — not installed properly
33
33
  }
34
34
 
35
- // Spawn detached background check so we don't block session startup
36
- const script = `
37
- const { execSync } = require('child_process');
38
- const fs = require('fs');
39
- const cachePath = ${JSON.stringify(cachePath)};
40
- const cacheDir = ${JSON.stringify(cacheDir)};
41
- const installed = ${JSON.stringify(installed)};
42
- try {
43
- const latest = execSync('npm view claude-code-pulsify version', {
44
- timeout: 10000,
45
- encoding: 'utf8',
46
- stdio: ['pipe', 'pipe', 'pipe']
47
- }).trim();
48
- fs.mkdirSync(cacheDir, { recursive: true });
49
- fs.writeFileSync(cachePath, JSON.stringify({
50
- installed,
51
- latest,
52
- updateAvailable: latest.localeCompare(installed, undefined, { numeric: true }) > 0,
53
- checkedAt: Date.now()
54
- }));
55
- } catch {
56
- // Network error or npm not available — silently ignore
57
- }
58
- `
59
-
60
- const child = spawn(process.execPath, ['-e', script], {
35
+ // Spawn detached background worker so we don't block session startup
36
+ const workerPath = path.join(__dirname, 'check-update-worker.js')
37
+ const child = spawn(process.execPath, [workerPath], {
61
38
  detached: true,
62
39
  stdio: 'ignore',
40
+ env: {
41
+ ...process.env,
42
+ PULSIFY_CACHE_PATH: cachePath,
43
+ PULSIFY_CACHE_DIR: cacheDir,
44
+ PULSIFY_INSTALLED: installed,
45
+ },
63
46
  })
64
47
  child.unref()
65
48
  }
66
49
 
67
- main().catch(() => process.exit(0))
50
+ main().catch((err) => {
51
+ if (process.env.CLAUDE_PULSIFY_DEBUG) {
52
+ process.stderr.write(`[pulsify:check-update] ${err.message}\n`)
53
+ }
54
+ process.exit(0)
55
+ })
@@ -7,8 +7,8 @@
7
7
  * warnings when context is running low.
8
8
  */
9
9
 
10
- const fs = require('fs')
11
- const path = require('path')
10
+ const fs = require('node:fs')
11
+ const path = require('node:path')
12
12
 
13
13
  // Thresholds on "used percentage" (normalized, 0-100)
14
14
  const WARNING_THRESHOLD = 65 // remaining ~35% usable
@@ -17,9 +17,16 @@ const CRITICAL_THRESHOLD = 75 // remaining ~25% usable
17
17
  // Debounce: minimum tool calls between repeated warnings at the same severity
18
18
  const DEBOUNCE_COUNT = 5
19
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
+ }
26
+
20
27
  // State file to track debounce across invocations
21
28
  function getStateFile(sessionId) {
22
- return `/tmp/claude-ctx-monitor-state-${sessionId}.json`
29
+ return `/tmp/claude-ctx-monitor-state-${sanitizeId(sessionId)}.json`
23
30
  }
24
31
 
25
32
  function readState(sessionId) {
@@ -55,7 +62,7 @@ async function main() {
55
62
  if (!sessionId) return
56
63
 
57
64
  // Read bridge file from statusline
58
- const bridgePath = `/tmp/claude-ctx-${sessionId}.json`
65
+ const bridgePath = `/tmp/claude-ctx-${sanitizeId(sessionId)}.json`
59
66
  let metrics
60
67
  try {
61
68
  metrics = JSON.parse(fs.readFileSync(bridgePath, 'utf8'))
@@ -63,8 +70,8 @@ async function main() {
63
70
  return // No bridge file yet -- statusline hasn't run
64
71
  }
65
72
 
66
- // Check staleness (>60s old)
67
- if (Date.now() - (metrics.timestamp || 0) > 60_000) return
73
+ // Check staleness
74
+ if (Date.now() - (metrics.timestamp || 0) > BRIDGE_STALE_MS) return
68
75
 
69
76
  const usedPct = metrics.used_percentage
70
77
  if (usedPct == null) return
@@ -112,4 +119,9 @@ async function main() {
112
119
  }
113
120
  }
114
121
 
115
- main().catch(() => process.exit(0))
122
+ main().catch((err) => {
123
+ if (process.env.CLAUDE_PULSIFY_DEBUG) {
124
+ process.stderr.write(`[pulsify:context-monitor] ${err.message}\n`)
125
+ }
126
+ process.exit(0)
127
+ })
@@ -7,8 +7,8 @@
7
7
  * and writes a bridge file for the context monitor.
8
8
  */
9
9
 
10
- const fs = require('fs')
11
- const path = require('path')
10
+ const fs = require('node:fs')
11
+ const path = require('node:path')
12
12
 
13
13
  // ANSI helpers
14
14
  const ESC = '\x1b['
@@ -28,9 +28,18 @@ const CYAN = FG(80, 200, 220)
28
28
 
29
29
  const SEPARATOR = `${GRAY}\u2502${RESET}`
30
30
 
31
- // Autocompact kicks in around 16.5% remaining -- normalize so bar reflects usable context
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.
32
37
  const AUTOCOMPACT_BUFFER = 16.5
33
38
 
39
+ function sanitizeId(id) {
40
+ return id.replace(/[^a-zA-Z0-9_-]/g, '')
41
+ }
42
+
34
43
  function normalizeUsage(remainingPct) {
35
44
  // remaining_percentage goes from 100 (empty) to 0 (full)
36
45
  // usable range is 100 down to ~16.5 (autocompact buffer)
@@ -41,21 +50,21 @@ function normalizeUsage(remainingPct) {
41
50
  }
42
51
 
43
52
  function getBarColor(usedPct) {
44
- if (usedPct < 50) return { fg: GREEN, label: GREEN }
45
- if (usedPct < 65) return { fg: YELLOW, label: YELLOW }
46
- if (usedPct < 80) return { fg: ORANGE, label: ORANGE }
47
- return { fg: RED, label: RED }
53
+ if (usedPct < 50) return GREEN
54
+ if (usedPct < 65) return YELLOW
55
+ if (usedPct < 80) return ORANGE
56
+ return RED
48
57
  }
49
58
 
50
- function buildProgressBar(usedPct, segments = 15) {
51
- const filled = Math.round((usedPct / 100) * segments)
52
- const empty = segments - filled
53
- const colors = getBarColor(usedPct)
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)
54
63
  const emphasis = usedPct >= 80 ? BOLD : ''
55
64
 
56
- const filledBar = `${emphasis}${colors.fg}${'█'.repeat(filled)}${RESET}`
65
+ const filledBar = `${emphasis}${color}${'█'.repeat(filled)}${RESET}`
57
66
  const emptyBar = `${GRAY}${'░'.repeat(empty)}${RESET}`
58
- const pctLabel = `${colors.label}${BOLD}${Math.round(usedPct)}%${RESET}`
67
+ const pctLabel = `${color}${BOLD}${Math.round(usedPct)}%${RESET}`
59
68
 
60
69
  return `${filledBar}${emptyBar} ${pctLabel}`
61
70
  }
@@ -65,11 +74,11 @@ function getActiveTask(data) {
65
74
  const active = todos.find((t) => t.status === 'in_progress') || todos.find((t) => t.status === 'pending')
66
75
  if (!active) return null
67
76
  const label = active.content || active.description || ''
68
- return label.length > 40 ? label.slice(0, 37) + '...' : label
77
+ return label.length > MAX_TASK_LABEL_LENGTH ? label.slice(0, MAX_TASK_LABEL_LENGTH - 3) + '...' : label
69
78
  }
70
79
 
71
80
  function getUpdateIndicator() {
72
- const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(require('os').homedir(), '.claude')
81
+ const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(require('node:os').homedir(), '.claude')
73
82
  const cachePath = path.join(configDir, 'cache', 'claude-code-pulsify-update.json')
74
83
  try {
75
84
  const cache = JSON.parse(fs.readFileSync(cachePath, 'utf8'))
@@ -94,14 +103,41 @@ function getGitBranch(cwd) {
94
103
 
95
104
  function writeBridgeFile(sessionId, metrics) {
96
105
  if (!sessionId) return
97
- const bridgePath = `/tmp/claude-ctx-${sessionId}.json`
106
+ const safeId = sanitizeId(sessionId)
107
+ const bridgePath = `/tmp/claude-ctx-${safeId}.json`
108
+ const tmpPath = `${bridgePath}.tmp`
98
109
  try {
99
- fs.writeFileSync(bridgePath, JSON.stringify({ ...metrics, timestamp: Date.now() }), 'utf8')
110
+ fs.writeFileSync(tmpPath, JSON.stringify({ ...metrics, timestamp: Date.now() }), 'utf8')
111
+ fs.renameSync(tmpPath, bridgePath)
100
112
  } catch {
101
113
  // Silently ignore write errors
102
114
  }
103
115
  }
104
116
 
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
+
105
141
  async function main() {
106
142
  let input = ''
107
143
  for await (const chunk of process.stdin) {
@@ -141,6 +177,11 @@ async function main() {
141
177
  const dir = path.basename(cwd)
142
178
  const remainingPct = data.context_window?.remaining_percentage ?? 100
143
179
  const sessionId = data.session?.id || data.session_id || null
180
+ const costUsd = data.cost?.total_cost_usd ?? 0
181
+ const linesAdded = data.cost?.total_lines_added ?? 0
182
+ const linesRemoved = data.cost?.total_lines_removed ?? 0
183
+ const totalInputTokens = data.context_window?.total_input_tokens ?? 0
184
+ const totalOutputTokens = data.context_window?.total_output_tokens ?? 0
144
185
 
145
186
  // Normalize and build bar
146
187
  const usedPct = normalizeUsage(remainingPct)
@@ -165,9 +206,29 @@ async function main() {
165
206
  model,
166
207
  })
167
208
 
209
+ // Format new segments
210
+ const cost = formatCost(costUsd)
211
+ const lines = formatLinesChanged(linesAdded, linesRemoved)
212
+ const tokenCount = formatTokenCount(totalInputTokens, totalOutputTokens)
213
+
214
+ // Build segments array (only include non-empty optional segments)
215
+ const segments = [
216
+ `${WHITE}${model}${RESET}`,
217
+ dirLabel,
218
+ ]
219
+ if (cost) segments.push(cost)
220
+ if (lines) segments.push(lines)
221
+ const barWithTokens = tokenCount ? `${bar} ${tokenCount}` : bar
222
+ segments.push(barWithTokens)
223
+
168
224
  // Output statusline
169
- const line = `${WHITE}${model}${RESET} ${SEPARATOR} ${dirLabel} ${SEPARATOR} ${bar}${taskSegment}${updateIndicator}`
225
+ const line = segments.join(` ${SEPARATOR} `) + taskSegment + updateIndicator
170
226
  process.stdout.write(line)
171
227
  }
172
228
 
173
- main().catch(() => process.exit(0))
229
+ main().catch((err) => {
230
+ if (process.env.CLAUDE_PULSIFY_DEBUG) {
231
+ process.stderr.write(`[pulsify:statusline] ${err.message}\n`)
232
+ }
233
+ process.exit(0)
234
+ })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-pulsify",
3
- "version": "1.0.2",
3
+ "version": "1.1.1",
4
4
  "description": "Context-aware statusline and context monitor for Claude Code",
5
5
  "bin": {
6
6
  "claude-code-pulsify": "bin/install.js"