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 +19 -18
- package/package.json +2 -4
- package/src/cli/daemon-client.ts +41 -6
- package/src/cli/setup.ts +24 -1
- package/src/cli/start.ts +7 -16
- package/src/cli/status.ts +22 -4
- package/src/cli/stop.ts +19 -36
- package/src/mcp/channel.ts +26 -8
- package/src/mcp/tools.ts +65 -2
- package/src/server/connection-manager.ts +11 -15
- package/src/server/connections/file.ts +7 -8
- package/src/server/connections/mqtt.ts +9 -10
- package/src/server/connections/webhook.ts +9 -10
- package/src/server/connections/websocket.ts +9 -10
- package/src/server/index.ts +14 -19
- package/src/server/stream-store.ts +128 -0
- package/src/server/watchers/engine.ts +7 -2
- package/src/server/watchers/manager.ts +80 -114
- package/src/shared/catalog-generators.ts +15 -7
- package/src/shared/command-catalog.ts +2 -2
- package/src/server/redis.ts +0 -62
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
#
|
|
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),
|
|
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
|
-
|
|
18
|
+
Set up and start Claude Code:
|
|
19
19
|
|
|
20
20
|
```bash
|
|
21
|
-
livetap
|
|
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
|
-
-
|
|
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
|
|
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
|
|
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
|
|
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 ──>
|
|
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 (
|
|
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
|
-
|
|
|
175
|
-
| Kafka | Planned
|
|
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
|
|
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
|
|
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
|
|
256
|
+
cd livetap
|
|
256
257
|
bun install
|
|
257
|
-
bun test #
|
|
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 `
|
|
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.
|
|
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
|
-
"
|
|
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"
|
package/src/cli/daemon-client.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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"
|
|
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
|
|
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 =
|
|
39
|
-
const
|
|
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',
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
}
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
}
|
package/src/mcp/channel.ts
CHANGED
|
@@ -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 =
|
|
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
|
|
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', '
|
|
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)
|
|
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
|
|
90
|
-
// Silently retry after delay
|
|
100
|
+
// SSE connection error — retry 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
|
|
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: '
|
|
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
|
|
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
|
-
|
|
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
|
}
|