livetap 0.1.4 → 0.2.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 CHANGED
@@ -1,4 +1,4 @@
1
- # livetap
1
+ # LiveTap
2
2
 
3
3
  > Push live data streams into your AI coding agent.
4
4
 
@@ -9,16 +9,16 @@ Connect MQTT brokers, WebSocket feeds, or tail log files. Your agent samples, wa
9
9
 
10
10
  ## Quick start
11
11
 
12
- **Requirements:** [Bun](https://bun.sh), [Redis](https://redis.io/) (`brew install redis`), Claude Code v2.1.80+
12
+ **Requirements:** [Bun](https://bun.sh), Claude Code v2.1.80+
13
13
 
14
14
  ```bash
15
15
  bun add livetap
16
16
  ```
17
17
 
18
- Start the daemon and Claude Code:
18
+ Set up and start Claude Code:
19
19
 
20
20
  ```bash
21
- livetap start
21
+ livetap setup
22
22
  claude --dangerously-load-development-channels server:livetap
23
23
  ```
24
24
 
@@ -34,7 +34,7 @@ Quick version:
34
34
 
35
35
  ```bash
36
36
  npm install livetap # Step 1: install
37
- npx livetap setup # Step 2: creates .mcp.json
37
+ npx livetap setup # Step 2: creates .mcp.json and starts daemon
38
38
  # Step 3: tell user to restart Claude Code with:
39
39
  # claude --dangerously-load-development-channels server:livetap --continue
40
40
  ```
@@ -42,12 +42,12 @@ npx livetap setup # Step 2: creates .mcp.json
42
42
  **IMPORTANT — do NOT:**
43
43
  - Add livetap to `~/.claude/mcp.json` — it goes in `.mcp.json` in the project root
44
44
  - Configure livetap as `type: "http"` — it is a stdio MCP server (command + args)
45
- - Start the daemon manually — the MCP proxy auto-starts it on Claude restart
45
+ - Worry about the daemon — setup starts it, and the MCP proxy auto-starts it if needed
46
46
  - Guess field names — always `read_stream` first to see actual payload structure
47
47
 
48
48
  ### After restart — use MCP tools
49
49
 
50
- Once restarted with the channel flag, you have 12 MCP tools available. Follow this workflow:
50
+ Once restarted with the channel flag, you have 13 MCP tools available. Follow this workflow:
51
51
 
52
52
  1. **CONNECT** — `create_connection({ type: "mqtt", broker: "broker.emqx.io", port: 1883, tls: false, topics: ["justinx/demo/#"] })`
53
53
  2. **SAMPLE** — `read_stream({ connectionId: "conn_xxx", backfillSeconds: 60, maxEntries: 10 })` — ALWAYS sample first to see field paths
@@ -74,17 +74,17 @@ Once restarted with the channel flag, you have 12 MCP tools available. Follow th
74
74
 
75
75
  ### If the daemon is not running
76
76
 
77
- The MCP proxy auto-starts the daemon. If it fails, run:
77
+ The daemon auto-starts on setup and on MCP proxy init, and retries on failed requests. If it still fails, run:
78
78
  ```bash
79
79
  livetap start
80
80
  ```
81
81
 
82
82
  ## What it does
83
83
 
84
- livetap runs a background daemon that connects to live data sources, buffers messages in embedded Redis, and pushes alerts into your Claude Code session via the [Channels API](https://code.claude.com/docs/en/channels). Your agent sees the data in real-time and can create expression-based watchers that fire when conditions match.
84
+ livetap runs a background daemon that connects to live data sources, buffers messages in memory, and pushes alerts into your Claude Code session via the [Channels API](https://code.claude.com/docs/en/channels). Your agent sees the data in real-time and can create expression-based watchers that fire when conditions match.
85
85
 
86
86
  ```
87
- Source (MQTT/WS/File) ──> Subscriber ──> Redis Stream ──> Watcher Engine
87
+ Source (MQTT/WS/File) ──> Subscriber ──> StreamStore ──> Watcher Engine
88
88
  | |
89
89
  v v (on match)
90
90
  read_stream Channel Alert
@@ -139,7 +139,7 @@ Agent: Taps the file, samples to see log format, creates regex watcher
139
139
 
140
140
  ```bash
141
141
  # Daemon
142
- livetap start # Start (embedded Redis + API)
142
+ livetap start # Start daemon (auto-started by setup)
143
143
  livetap stop # Stop
144
144
  livetap status # Dashboard
145
145
 
@@ -171,12 +171,12 @@ livetap unwatch <watcherId> # Remove
171
171
  | **MQTT** | `livetap tap mqtt://broker.emqx.io:1883/sensors/#` | IoT sensors, home automation |
172
172
  | **WebSocket** | `livetap tap wss://stream.binance.com:9443/ws/btcusdt@trade` | Finance, real-time APIs |
173
173
  | **File tailing** | `livetap tap file:///var/log/nginx/error.log` | Log monitoring, DevOps |
174
- | Webhooks | Planned v0.1 | CI/CD, external services |
175
- | Kafka | Planned v0.2 | Event sourcing, analytics |
174
+ | **Webhook** | `livetap tap webhook` | CI/CD, external services |
175
+ | Kafka | Planned | Event sourcing, analytics |
176
176
 
177
177
  ## MCP tools
178
178
 
179
- livetap exposes 12 MCP tools that your agent uses automatically:
179
+ livetap exposes 13 MCP tools that your agent uses automatically:
180
180
 
181
181
  | Tool | What it does |
182
182
  |------|-------------|
@@ -192,6 +192,7 @@ livetap exposes 12 MCP tools that your agent uses automatically:
192
192
  | `update_watcher` | Change conditions or cooldown |
193
193
  | `delete_watcher` | Remove a watcher |
194
194
  | `restart_watcher` | Restart a stopped watcher |
195
+ | `status` | Daemon health, uptime, connections, and watchers summary |
195
196
 
196
197
  ## Expression watchers
197
198
 
@@ -232,7 +233,7 @@ The agent knows field paths differ by source:
232
233
 
233
234
  **Daemon port:** Default `:8788`. Override with `--port` or `LIVETAP_PORT` env var.
234
235
 
235
- **State directory:** `~/.livetap/` stores daemon PID, logs, and watcher evaluation logs.
236
+ **State directory:** `~/.livetap/` stores `daemon.pid`, daemon logs, and watcher evaluation logs.
236
237
 
237
238
  **MCP config:** `.mcp.json` in your project root:
238
239
  ```json
@@ -252,9 +253,9 @@ The agent knows field paths differ by source:
252
253
 
253
254
  ```bash
254
255
  git clone https://github.com/livetap/livetap.git
255
- cd livetap && git checkout v0
256
+ cd livetap
256
257
  bun install
257
- bun test # 103 tests
258
+ bun test # 121 tests
258
259
  bun test tests/phase1/ # Specific phase
259
260
  SKIP_LIVE_MQTT=1 bun test # Skip tests needing broker.emqx.io
260
261
  ```
@@ -267,7 +268,7 @@ See [docs/PLAN.md](docs/PLAN.md) for the full build plan with phased architectur
267
268
  2. `bun install && bun test`
268
269
  3. Make changes, add tests
269
270
  4. `bun test` must pass
270
- 5. PR to `v0` branch
271
+ 5. PR to `main` branch
271
272
 
272
273
  See [docs/PLAN.md](docs/PLAN.md) for architecture and module layout.
273
274
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "livetap",
3
- "version": "0.1.4",
3
+ "version": "0.2.0",
4
4
  "description": "Push live data streams into your AI coding agent. LLM/AI agent? Run: npx livetap --llm-help",
5
5
  "type": "module",
6
6
  "bin": {
@@ -28,9 +28,7 @@
28
28
  ],
29
29
  "dependencies": {
30
30
  "@modelcontextprotocol/sdk": "^1.28.0",
31
- "ioredis": "^5.10.1",
32
- "mqtt": "^5.15.1",
33
- "redis-server": "^1.2.2"
31
+ "mqtt": "^5.15.1"
34
32
  },
35
33
  "devDependencies": {
36
34
  "@types/bun": "latest"
@@ -4,15 +4,22 @@
4
4
 
5
5
  import { resolve } from 'path'
6
6
  import { homedir } from 'os'
7
- import { existsSync, readFileSync } from 'fs'
7
+ import { existsSync, readFileSync, unlinkSync } from 'fs'
8
8
 
9
- const STATE_PATH = resolve(homedir(), '.livetap', 'state.json')
9
+ const STATE_DIR = resolve(homedir(), '.livetap')
10
+ const PID_PATH = resolve(STATE_DIR, 'daemon.pid')
11
+
12
+ export { PID_PATH, STATE_DIR }
10
13
 
11
14
  export function getDaemonUrl(): string {
12
15
  const port = process.env.LIVETAP_PORT || '8788'
13
16
  return `http://127.0.0.1:${port}`
14
17
  }
15
18
 
19
+ export function getDaemonPort(): number {
20
+ return parseInt(process.env.LIVETAP_PORT || '8788')
21
+ }
22
+
16
23
  export async function isDaemonRunning(): Promise<boolean> {
17
24
  try {
18
25
  const res = await fetch(`${getDaemonUrl()}/status`)
@@ -22,9 +29,37 @@ export async function isDaemonRunning(): Promise<boolean> {
22
29
  }
23
30
  }
24
31
 
25
- export function requireDaemon() {
26
- // Called at start of commands that need the daemon
27
- // Actual check is async, so this just prints the message format
32
+ /**
33
+ * Read PID from daemon.pid file. Returns undefined if missing or stale.
34
+ */
35
+ export function readPid(): number | undefined {
36
+ try {
37
+ if (!existsSync(PID_PATH)) return undefined
38
+ const pid = parseInt(readFileSync(PID_PATH, 'utf-8').trim())
39
+ if (isNaN(pid)) return undefined
40
+ return pid
41
+ } catch {
42
+ return undefined
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Check if a PID is alive.
48
+ */
49
+ export function isPidAlive(pid: number): boolean {
50
+ try {
51
+ process.kill(pid, 0)
52
+ return true
53
+ } catch {
54
+ return false
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Clean up stale PID file.
60
+ */
61
+ export function cleanPidFile(): void {
62
+ try { unlinkSync(PID_PATH) } catch { /* ok */ }
28
63
  }
29
64
 
30
65
  export async function daemonFetch(path: string, opts?: RequestInit): Promise<Response> {
@@ -32,7 +67,7 @@ export async function daemonFetch(path: string, opts?: RequestInit): Promise<Res
32
67
  try {
33
68
  return await fetch(url, opts)
34
69
  } catch (err) {
35
- console.error('Error: livetap daemon is not running. Use "livetap start" first.')
70
+ console.error('Error: livetap daemon is not running. Use "livetap start" or "livetap setup" to start it.')
36
71
  process.exit(1)
37
72
  }
38
73
  }
package/src/cli/setup.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  /**
2
- * livetap setup — creates .mcp.json and prints restart instructions.
2
+ * livetap setup — creates .mcp.json, starts the daemon, and prints restart instructions.
3
3
  * Works with both npm and bun. Node-compatible (no bun APIs).
4
4
  */
5
5
 
6
6
  import { existsSync, readFileSync, writeFileSync } from 'fs'
7
7
  import { resolve } from 'path'
8
+ import { isDaemonRunning } from './daemon-client.js'
8
9
 
9
10
  export async function run(_args: string[]) {
10
11
  const root = process.cwd()
@@ -60,6 +61,28 @@ export async function run(_args: string[]) {
60
61
  console.log('✓ .mcp.json created with livetap MCP server entry')
61
62
  }
62
63
 
64
+ // Start the daemon if not running
65
+ if (await isDaemonRunning()) {
66
+ console.log('✓ Daemon already running')
67
+ } else {
68
+ process.stdout.write(' Starting daemon...')
69
+ // Import and call start.ts run() to start the daemon
70
+ const { run: startDaemon } = await import('./start.js')
71
+ // Capture console.log output from start.ts
72
+ const origLog = console.log
73
+ let startMsg = ''
74
+ console.log = (msg: string) => { startMsg = msg }
75
+ await startDaemon([])
76
+ console.log = origLog
77
+
78
+ if (startMsg.includes('started') || startMsg.includes('already running')) {
79
+ process.stdout.write('\r✓ Daemon started \n')
80
+ } else {
81
+ process.stdout.write('\r✗ Daemon failed to start \n')
82
+ console.error(' Run "livetap start" manually to debug.')
83
+ }
84
+ }
85
+
63
86
  console.log('')
64
87
  console.log('→ Next step: restart Claude Code with:')
65
88
  console.log(' claude --dangerously-load-development-channels server:livetap --continue')
package/src/cli/start.ts CHANGED
@@ -4,12 +4,10 @@
4
4
 
5
5
  import { resolve } from 'path'
6
6
  import { homedir } from 'os'
7
- import { mkdirSync, writeFileSync, existsSync } from 'fs'
8
- import { isDaemonRunning, getDaemonUrl } from './daemon-client.js'
7
+ import { mkdirSync, writeFileSync } from 'fs'
8
+ import { isDaemonRunning, getDaemonUrl, getDaemonPort, PID_PATH, STATE_DIR } from './daemon-client.js'
9
9
 
10
- const STATE_DIR = resolve(homedir(), '.livetap')
11
10
  const LOG_DIR = resolve(STATE_DIR, 'logs')
12
- const STATE_PATH = resolve(STATE_DIR, 'state.json')
13
11
 
14
12
  export async function run(args: string[]) {
15
13
  const foreground = args.includes('--foreground') || args.includes('-f')
@@ -29,21 +27,20 @@ export async function run(args: string[]) {
29
27
 
30
28
  if (foreground) {
31
29
  console.log('Starting livetap in foreground...')
32
- // Import and run directly
33
30
  await import('../server/index.js')
34
31
  return
35
32
  }
36
33
 
37
34
  // Background: spawn detached
38
- const port = process.env.LIVETAP_PORT || '8788'
39
- const logFile = Bun.file(resolve(LOG_DIR, 'daemon.log'))
40
- const logFd = logFile.writer()
35
+ const port = String(getDaemonPort())
36
+ const daemonEntry = resolve(import.meta.dir, '../server/index.ts')
41
37
 
42
- const proc = Bun.spawn(['bun', resolve(import.meta.dir, '../server/index.ts')], {
38
+ const proc = Bun.spawn(['bun', daemonEntry], {
43
39
  env: { ...process.env, LIVETAP_PORT: port },
44
40
  stdout: 'ignore',
45
41
  stderr: 'ignore',
46
42
  })
43
+ proc.unref()
47
44
 
48
45
  // Wait for it to be ready
49
46
  const deadline = Date.now() + 15_000
@@ -52,13 +49,7 @@ export async function run(args: string[]) {
52
49
  try {
53
50
  const res = await fetch(`http://127.0.0.1:${port}/status`)
54
51
  if (res.ok) {
55
- const data = await res.json()
56
- writeFileSync(STATE_PATH, JSON.stringify({
57
- pid: proc.pid,
58
- port: parseInt(port),
59
- redisPort: data.redisPort,
60
- startedAt: new Date().toISOString(),
61
- }, null, 2))
52
+ writeFileSync(PID_PATH, String(proc.pid))
62
53
  ready = true
63
54
  break
64
55
  }
package/src/cli/status.ts CHANGED
@@ -2,11 +2,16 @@
2
2
  * livetap status — Show daemon, connections, and watchers.
3
3
  */
4
4
 
5
- import { isDaemonRunning, daemonJson } from './daemon-client.js'
5
+ import { isDaemonRunning, daemonJson, readPid } from './daemon-client.js'
6
6
 
7
7
  export async function run(args: string[]) {
8
8
  if (!(await isDaemonRunning())) {
9
- console.log('livetap is not running. Use "livetap start" to begin.')
9
+ const pid = readPid()
10
+ if (pid) {
11
+ console.log(`livetap daemon is not responding (stale PID ${pid}). Try "livetap start".`)
12
+ } else {
13
+ console.log('livetap is not running. Use "livetap start" or "livetap setup" to begin.')
14
+ }
10
15
  return
11
16
  }
12
17
 
@@ -19,8 +24,7 @@ export async function run(args: string[]) {
19
24
  }
20
25
 
21
26
  const uptime = formatUptime(data.uptime)
22
- console.log(`livetap daemon running on :${data.port} (uptime ${uptime})`)
23
- console.log(`Redis: localhost:${data.redisPort}\n`)
27
+ console.log(`livetap daemon running on :${data.port} (uptime ${uptime})\n`)
24
28
 
25
29
  const conns = data.connections || []
26
30
  if (conns.length === 0) {
@@ -34,6 +38,20 @@ export async function run(args: string[]) {
34
38
  }
35
39
  console.log()
36
40
  }
41
+
42
+ // Fetch watcher count
43
+ try {
44
+ const watchers = await daemonJson('/watchers')
45
+ if (watchers.length > 0) {
46
+ const running = watchers.filter((w: any) => w.status === 'running').length
47
+ console.log(`Watchers (${watchers.length}, ${running} running)`)
48
+ for (const w of watchers) {
49
+ const expr = w.conditions?.map((c: any) => `${c.field} ${c.op} ${c.value}`).join(w.match === 'all' ? ' AND ' : ' OR ') ?? ''
50
+ console.log(` ${w.id} ${w.status.padEnd(8)} ${expr.slice(0, 50)}`)
51
+ }
52
+ console.log()
53
+ }
54
+ } catch { /* no watchers endpoint or error */ }
37
55
  }
38
56
 
39
57
  function formatUptime(seconds: number): string {
package/src/cli/stop.ts CHANGED
@@ -2,45 +2,32 @@
2
2
  * livetap stop — Stop the daemon.
3
3
  */
4
4
 
5
- import { resolve } from 'path'
6
- import { homedir } from 'os'
7
- import { existsSync, readFileSync, unlinkSync } from 'fs'
8
- import { isDaemonRunning, getDaemonUrl } from './daemon-client.js'
9
-
10
- const STATE_PATH = resolve(homedir(), '.livetap', 'state.json')
5
+ import { isDaemonRunning, readPid, isPidAlive, cleanPidFile, getDaemonPort } from './daemon-client.js'
11
6
 
12
7
  export async function run(_args: string[]) {
13
- if (!(await isDaemonRunning())) {
14
- // Try reading PID from state file
15
- if (existsSync(STATE_PATH)) {
16
- try {
17
- const state = JSON.parse(readFileSync(STATE_PATH, 'utf-8'))
18
- try { process.kill(state.pid, 'SIGTERM') } catch { /* already dead */ }
19
- unlinkSync(STATE_PATH)
20
- console.log('livetap daemon stopped (cleaned up stale state)')
21
- } catch {
22
- unlinkSync(STATE_PATH)
23
- }
24
- } else {
25
- console.log('livetap is not running.')
26
- }
8
+ const pid = readPid()
9
+ const running = await isDaemonRunning()
10
+
11
+ if (!running && !pid) {
12
+ console.log('livetap is not running.')
27
13
  return
28
14
  }
29
15
 
30
- // Read state for PID
31
- let pid: number | undefined
32
- if (existsSync(STATE_PATH)) {
33
- try {
34
- const state = JSON.parse(readFileSync(STATE_PATH, 'utf-8'))
35
- pid = state.pid
36
- } catch { /* malformed */ }
16
+ if (!running && pid) {
17
+ // PID file exists but daemon isn't responding
18
+ if (isPidAlive(pid)) {
19
+ // Process exists but not responding on port — might be something else
20
+ console.log(`Warning: PID ${pid} exists but daemon is not responding on :${getDaemonPort()}`)
21
+ try { process.kill(pid, 'SIGTERM') } catch { /* ok */ }
22
+ }
23
+ cleanPidFile()
24
+ console.log('livetap daemon stopped (cleaned up stale state)')
25
+ return
37
26
  }
38
27
 
39
- // Send SIGTERM
28
+ // Daemon is running — send SIGTERM via PID or wait for port to close
40
29
  if (pid) {
41
- try {
42
- process.kill(pid, 'SIGTERM')
43
- } catch { /* already gone */ }
30
+ try { process.kill(pid, 'SIGTERM') } catch { /* already gone */ }
44
31
  }
45
32
 
46
33
  // Wait for shutdown
@@ -55,10 +42,6 @@ export async function run(_args: string[]) {
55
42
  if (pid) try { process.kill(pid, 'SIGKILL') } catch { /* ok */ }
56
43
  }
57
44
 
58
- // Clean up state
59
- if (existsSync(STATE_PATH)) {
60
- try { unlinkSync(STATE_PATH) } catch { /* ok */ }
61
- }
62
-
45
+ cleanPidFile()
63
46
  console.log('livetap daemon stopped')
64
47
  }
@@ -10,15 +10,18 @@
10
10
 
11
11
  import { Server } from '@modelcontextprotocol/sdk/server/index.js'
12
12
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
13
+ import { readFileSync } from 'fs'
13
14
  import { registerTools } from './tools.js'
14
15
  import { generateInstructions } from '../shared/catalog-generators.js'
15
16
 
17
+ const PKG = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url).pathname, 'utf-8'))
18
+
16
19
  const DAEMON_PORT = parseInt(process.env.LIVETAP_PORT || '8788')
17
20
  const DAEMON_URL = `http://127.0.0.1:${DAEMON_PORT}`
18
21
 
19
22
  const INSTRUCTIONS = generateInstructions()
20
23
 
21
- async function waitForDaemon(maxWaitMs = 10_000): Promise<boolean> {
24
+ async function waitForDaemon(maxWaitMs = 30_000): Promise<boolean> {
22
25
  const deadline = Date.now() + maxWaitMs
23
26
  while (Date.now() < deadline) {
24
27
  try {
@@ -37,13 +40,15 @@ async function autoStartDaemon(): Promise<boolean> {
37
40
  if (res.ok) return true
38
41
  } catch { /* not running */ }
39
42
 
40
- // Auto-start the daemon
43
+ // Auto-start via CLI (single code path — resolves paths correctly from any CWD)
44
+ const startScript = new URL('../../bin/livetap.ts', import.meta.url).pathname
41
45
  console.error('[livetap-mcp] Daemon not running, auto-starting...')
42
- Bun.spawn(['bun', 'src/server/index.ts'], {
46
+ const proc = Bun.spawn(['bun', startScript, 'start'], {
43
47
  env: { ...process.env, LIVETAP_PORT: String(DAEMON_PORT) },
44
48
  stdout: 'ignore',
45
49
  stderr: 'ignore',
46
50
  })
51
+ proc.unref()
47
52
 
48
53
  return waitForDaemon()
49
54
  }
@@ -51,7 +56,11 @@ async function autoStartDaemon(): Promise<boolean> {
51
56
  async function connectToSSE(mcp: Server) {
52
57
  try {
53
58
  const res = await fetch(`${DAEMON_URL}/events`)
54
- if (!res.ok || !res.body) return
59
+ if (!res.ok || !res.body) {
60
+ // Daemon not ready — retry
61
+ setTimeout(() => connectToSSE(mcp), 5000)
62
+ return
63
+ }
55
64
 
56
65
  const reader = res.body.getReader()
57
66
  const decoder = new TextDecoder()
@@ -85,9 +94,10 @@ async function connectToSSE(mcp: Server) {
85
94
  }
86
95
  }
87
96
  }
97
+ // Clean disconnect (daemon died or restarted) — retry
98
+ setTimeout(() => connectToSSE(mcp), 5000)
88
99
  } catch {
89
- // SSE connection faileddaemon may not support /events yet (Phase 3)
90
- // Silently retry after delay
100
+ // SSE connection errorretry after delay
91
101
  setTimeout(() => connectToSSE(mcp), 5000)
92
102
  }
93
103
  }
@@ -95,11 +105,11 @@ async function connectToSSE(mcp: Server) {
95
105
  async function main() {
96
106
  const daemonReady = await autoStartDaemon()
97
107
  if (!daemonReady) {
98
- console.error('[livetap-mcp] WARNING: Could not connect to daemon. Tools may fail.')
108
+ console.error('[livetap-mcp] WARNING: Could not connect to daemon. Tools will auto-retry on first call.')
99
109
  }
100
110
 
101
111
  const mcp = new Server(
102
- { name: 'livetap', version: '0.1.0' },
112
+ { name: 'LiveTap', version: PKG.version },
103
113
  {
104
114
  capabilities: {
105
115
  experimental: { 'claude/channel': {} },
@@ -118,4 +128,12 @@ async function main() {
118
128
  connectToSSE(mcp)
119
129
  }
120
130
 
131
+ // Prevent unhandled errors from crashing the proxy process
132
+ process.on('unhandledRejection', (err) => {
133
+ console.error('[livetap-mcp] unhandled rejection:', err)
134
+ })
135
+ process.on('uncaughtException', (err) => {
136
+ console.error('[livetap-mcp] uncaught exception:', err)
137
+ })
138
+
121
139
  main()
package/src/mcp/tools.ts CHANGED
@@ -50,7 +50,7 @@ export const TOOLS = [
50
50
  },
51
51
  {
52
52
  name: 'destroy_connection',
53
- description: 'Destroy a connection — stops the source subscriber, cleans up the Redis stream.',
53
+ description: 'Destroy a connection — stops the source subscriber, cleans up the stream buffer.',
54
54
  inputSchema: {
55
55
  type: 'object' as const,
56
56
  properties: {
@@ -169,6 +169,14 @@ export const TOOLS = [
169
169
  required: ['watcherId'],
170
170
  },
171
171
  },
172
+ {
173
+ name: 'status',
174
+ description: 'Get daemon status: uptime, port, active connections and watchers count.',
175
+ inputSchema: {
176
+ type: 'object' as const,
177
+ properties: {},
178
+ },
179
+ },
172
180
  ]
173
181
 
174
182
  function text(content: string) {
@@ -179,9 +187,35 @@ function error(content: string) {
179
187
  return { content: [{ type: 'text' as const, text: content }], isError: true }
180
188
  }
181
189
 
190
+ /**
191
+ * Try to start the daemon via CLI. Returns true if daemon becomes healthy.
192
+ */
193
+ async function tryStartDaemon(daemonUrl: string): Promise<boolean> {
194
+ const startScript = new URL('../../bin/livetap.ts', import.meta.url).pathname
195
+ const port = new URL(daemonUrl).port || '8788'
196
+ const proc = Bun.spawn(['bun', startScript, 'start'], {
197
+ env: { ...process.env, LIVETAP_PORT: port },
198
+ stdout: 'ignore',
199
+ stderr: 'ignore',
200
+ })
201
+ proc.unref()
202
+
203
+ // Wait up to 15s for daemon to be ready
204
+ const deadline = Date.now() + 15_000
205
+ while (Date.now() < deadline) {
206
+ try {
207
+ const res = await fetch(`${daemonUrl}/status`)
208
+ if (res.ok) return true
209
+ } catch { /* not ready */ }
210
+ await new Promise((r) => setTimeout(r, 500))
211
+ }
212
+ return false
213
+ }
214
+
182
215
  /**
183
216
  * Register all livetap MCP tools on the given server.
184
217
  * Tools proxy to the daemon HTTP API at the given base URL.
218
+ * Includes auto-restart + retry on connection failure.
185
219
  */
186
220
  export function registerTools(server: Server, daemonUrl: string) {
187
221
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
@@ -304,11 +338,40 @@ export function registerTools(server: Server, daemonUrl: string) {
304
338
  return text(await res.text())
305
339
  }
306
340
 
341
+ case 'status': {
342
+ const res = await fetch(`${daemonUrl}/status`)
343
+ if (!res.ok) return error('Daemon not reachable')
344
+ const status = await res.json()
345
+ // Also fetch watcher count
346
+ let watcherCount = 0
347
+ try {
348
+ const wr = await fetch(`${daemonUrl}/watchers`)
349
+ if (wr.ok) {
350
+ const watchers = await wr.json()
351
+ watcherCount = watchers.length
352
+ }
353
+ } catch { /* ok */ }
354
+ return text(JSON.stringify({ ...status, watcherCount }, null, 2))
355
+ }
356
+
307
357
  default:
308
358
  return error(`Unknown tool: ${name}`)
309
359
  }
310
360
  } catch (err) {
311
- return error(`livetap daemon error: ${(err as Error).message}. Is the daemon running?`)
361
+ // Connection failed try to auto-start daemon and retry
362
+ const msg = (err as Error).message
363
+ if (msg.includes('Unable to connect') || msg.includes('ECONNREFUSED') || msg.includes('fetch failed')) {
364
+ const started = await tryStartDaemon(daemonUrl)
365
+ if (!started) {
366
+ return error('livetap daemon could not be started. Run "livetap start" manually.')
367
+ }
368
+ // Retry: simple fetch to /status to confirm, then tell agent to retry
369
+ return text(JSON.stringify({
370
+ note: 'Daemon was restarted. Please retry your request.',
371
+ status: 'daemon_restarted',
372
+ }))
373
+ }
374
+ return error(`livetap daemon error: ${msg}`)
312
375
  }
313
376
  })
314
377
  }