claude-view 0.42.0 → 0.44.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.
Files changed (3) hide show
  1. package/index.js +52 -23
  2. package/package.json +2 -2
  3. package/supervise.js +111 -0
package/index.js CHANGED
@@ -6,6 +6,7 @@ const path = require('path')
6
6
  const os = require('os')
7
7
  const https = require('https')
8
8
  const zlib = require('zlib')
9
+ const { classifyExit, superviseServer } = require('./supervise')
9
10
 
10
11
  const VERSION = require('./package.json').version
11
12
  const REPO = 'tombelieber/claude-view'
@@ -213,37 +214,65 @@ async function main() {
213
214
  env.SIDECAR_DIR = sidecarDir
214
215
  }
215
216
 
216
- // Run the server, forwarding signals and exit code.
217
+ // Run the server under a bounded crash-supervisor (see ./supervise.js).
217
218
  //
218
- // Signal handling strategy:
219
- // - SIGINT: Don't forward. Terminal sends SIGINT to the entire process group,
220
- // so the child already receives it. Forwarding would double-deliver, causing
221
- // the Rust server's graceful shutdown to see two SIGINTs from one Ctrl+C.
222
- // - SIGTERM/SIGHUP: Forward. These may be sent to our PID only (e.g. from
223
- // `kill` or a process manager), so the child wouldn't get them otherwise.
224
- const child = spawn(binaryPath, process.argv.slice(2), { stdio: 'inherit', env })
219
+ // The server is long-running; an abnormal death — a panic=abort SIGABRT, an
220
+ // OS out-of-memory SIGKILL, or a non-zero exit — used to leave the user with
221
+ // a dead app until they manually relaunched. We now restart it on an abnormal
222
+ // exit, bounded to MAX_RESTARTS within RESTART_WINDOW_MS so a hard crash-loop
223
+ // gives up loudly instead of thrashing.
224
+ //
225
+ // Signal handling (intent unchanged):
226
+ // - SIGINT: the terminal delivers it to the whole process group, so the child
227
+ // already receives it. The wrapper ignores it (so Node doesn't exit first)
228
+ // and treats the child's SIGINT death as a deliberate stop — never restart.
229
+ // - SIGTERM/SIGHUP: forward to whichever child is currently running (it may
230
+ // not get them if they were sent to our PID only); also a deliberate stop.
231
+ const MAX_RESTARTS = 5
232
+ const RESTART_WINDOW_MS = 60_000
225
233
 
226
234
  // Prevent Node from exiting on SIGINT before the child does.
227
235
  const ignoreSigint = () => {}
228
236
  process.on('SIGINT', ignoreSigint)
229
237
 
230
- // Forward SIGTERM/SIGHUP — child may not receive these from process group.
231
- for (const sig of ['SIGTERM', 'SIGHUP']) {
232
- process.on(sig, () => child.kill(sig))
233
- }
234
-
235
- child.on('exit', (code, signal) => {
236
- // Remove our SIGINT handler so the re-signal below uses default behavior.
237
- process.removeListener('SIGINT', ignoreSigint)
238
+ const supervisor = superviseServer({
239
+ spawn: () => spawn(binaryPath, process.argv.slice(2), { stdio: 'inherit', env }),
240
+ maxRestarts: MAX_RESTARTS,
241
+ windowMs: RESTART_WINDOW_MS,
242
+ now: () => Date.now(),
243
+ log: (msg) => console.error(msg),
244
+ onExit: ({ code, signal }) => {
245
+ // Terminal disposition: restore default SIGINT behavior, then propagate
246
+ // the child's fate faithfully so the parent shell sees the right status.
247
+ process.removeListener('SIGINT', ignoreSigint)
248
+
249
+ // Reaching here with a crash disposition means the supervisor exhausted
250
+ // its restart budget — point the user at the evidence instead of dying
251
+ // silently.
252
+ if (classifyExit(code, signal) === 'crash') {
253
+ const logDir = path.join(os.homedir(), '.claude-view', 'logs')
254
+ console.error(
255
+ `\nCheck ${path.join(logDir, 'crash-*.log')} for a panic backtrace, ` +
256
+ `or your system log for an out-of-memory (jetsam) kill.`,
257
+ )
258
+ }
238
259
 
239
- if (signal) {
240
- // Child was killed by signal — re-signal ourselves so the parent shell
241
- // sees the correct exit status (128 + signal number).
242
- process.kill(process.pid, signal)
243
- } else {
244
- process.exit(code ?? 1)
245
- }
260
+ if (signal) {
261
+ // Re-signal ourselves so the parent shell sees 128 + signal number.
262
+ process.kill(process.pid, signal)
263
+ } else {
264
+ process.exit(code ?? 1)
265
+ }
266
+ },
246
267
  })
268
+
269
+ // Forward deliberate-stop signals to whichever child is currently running.
270
+ for (const sig of ['SIGTERM', 'SIGHUP']) {
271
+ process.on(sig, () => {
272
+ const child = supervisor.getChild()
273
+ if (child) child.kill(sig)
274
+ })
275
+ }
247
276
  }
248
277
 
249
278
  main()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-view",
3
- "version": "0.42.0",
3
+ "version": "0.44.0",
4
4
  "description": "You have 10 Claude sessions running. What are they doing? Live dashboard for Claude Code — monitor every session, track costs, search history, see sub-agents. One command: npx claude-view",
5
5
  "bin": {
6
6
  "claude-view": "./index.js"
@@ -57,5 +57,5 @@
57
57
  "engines": {
58
58
  "node": ">=16"
59
59
  },
60
- "files": ["index.js", "README.md"]
60
+ "files": ["index.js", "supervise.js", "README.md"]
61
61
  }
package/supervise.js ADDED
@@ -0,0 +1,111 @@
1
+ // Crash-supervisor decision logic for the npx-cli launcher.
2
+ //
3
+ // The launcher spawns the Rust `claude-view` server as a child. Historically
4
+ // the wrapper relayed the child's exit verbatim — so ANY abnormal death of the
5
+ // server (a panic=abort SIGABRT, an OS OOM-kill SIGKILL, a non-zero exit) left
6
+ // the user staring at a dead app until they manually restarted. This module
7
+ // makes the launcher self-heal: restart the server on an abnormal exit, bounded
8
+ // to `maxRestarts` within a rolling `windowMs` so a hard crash-loop gives up
9
+ // loudly instead of thrashing forever.
10
+ //
11
+ // Pure functions, no side effects — the runtime loop in index.js injects the
12
+ // clock and the crash history so the policy is fully testable.
13
+
14
+ // Signals that mean "someone deliberately asked us to stop" — propagate, never
15
+ // restart. Everything else that kills the process (SIGKILL/SIGABRT/SIGSEGV/...)
16
+ // is treated as a crash worth recovering from.
17
+ const USER_STOP_SIGNALS = new Set(['SIGINT', 'SIGTERM', 'SIGHUP'])
18
+
19
+ /**
20
+ * Classify a child exit into one of: 'normal' | 'user-stop' | 'crash'.
21
+ * @param {number|null} code exit code (null when killed by a signal)
22
+ * @param {string|null} signal terminating signal name (null on normal exit)
23
+ */
24
+ function classifyExit(code, signal) {
25
+ if (signal) {
26
+ return USER_STOP_SIGNALS.has(signal) ? 'user-stop' : 'crash'
27
+ }
28
+ return code === 0 ? 'normal' : 'crash'
29
+ }
30
+
31
+ /**
32
+ * Decide whether to restart, given the recent crash history.
33
+ * Only crashes are restartable, and only while fewer than `maxRestarts`
34
+ * crashes have occurred within the trailing `windowMs`.
35
+ * @returns {boolean}
36
+ */
37
+ function shouldRestart({ exitClass, crashTimes, now, maxRestarts, windowMs }) {
38
+ if (exitClass !== 'crash') return false
39
+ const recent = crashTimes.filter((t) => now - t < windowMs)
40
+ return recent.length < maxRestarts
41
+ }
42
+
43
+ /**
44
+ * Run a child process under a bounded crash-supervisor.
45
+ *
46
+ * Dependency-injected so the restart loop is testable without real processes:
47
+ * - `spawn()` -> returns a child (an EventEmitter emitting 'exit'/'error')
48
+ * - `now()` -> current epoch ms (injected clock)
49
+ * - `log(msg)` -> visible progress/diagnostic line
50
+ * - `onExit({code, signal})` -> terminal disposition (caller maps to process exit)
51
+ *
52
+ * Restarts only abnormal deaths, at most `maxRestarts` within `windowMs`;
53
+ * beyond that it gives up and propagates the last disposition.
54
+ *
55
+ * @returns {{ getChild: () => any, crashCount: () => number }}
56
+ */
57
+ function superviseServer({ spawn, maxRestarts, windowMs, now, log, onExit }) {
58
+ const crashTimes = []
59
+ let child = null
60
+
61
+ const launch = () => {
62
+ // Per-launch guard: a failed spawn can emit BOTH 'error' and 'exit'; we
63
+ // must act on exactly one of them.
64
+ let settled = false
65
+ const settle = (disposition, restart) => {
66
+ if (settled) return
67
+ settled = true
68
+ if (restart) {
69
+ launch()
70
+ } else {
71
+ onExit(disposition)
72
+ }
73
+ }
74
+
75
+ child = spawn()
76
+
77
+ child.on('error', (err) => {
78
+ // Spawn-level failure (binary missing / not executable) is a permanent
79
+ // install problem, not a runtime crash — surface it, don't retry.
80
+ log(`\nFailed to launch claude-view server: ${err.message}`)
81
+ settle({ code: 1, signal: null }, false)
82
+ })
83
+
84
+ child.on('exit', (code, signal) => {
85
+ const exitClass = classifyExit(code, signal)
86
+ if (exitClass === 'crash') {
87
+ const t = now()
88
+ if (shouldRestart({ exitClass, crashTimes, now: t, maxRestarts, windowMs })) {
89
+ crashTimes.push(t)
90
+ const reason = signal ? `signal ${signal}` : `exit code ${code}`
91
+ log(
92
+ `\nclaude-view server died (${reason}) — restarting ` +
93
+ `(${crashTimes.length}/${maxRestarts} within ${Math.round(windowMs / 1000)}s)...`,
94
+ )
95
+ settle(null, true)
96
+ return
97
+ }
98
+ log(
99
+ `\nclaude-view server crashed ${maxRestarts} times within ` +
100
+ `${Math.round(windowMs / 1000)}s — giving up.`,
101
+ )
102
+ }
103
+ settle({ code, signal }, false)
104
+ })
105
+ }
106
+
107
+ launch()
108
+ return { getChild: () => child, crashCount: () => crashTimes.length }
109
+ }
110
+
111
+ module.exports = { classifyExit, shouldRestart, superviseServer, USER_STOP_SIGNALS }