walkie-sh 0.1.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/LICENSE +21 -0
- package/README.md +74 -0
- package/bin/walkie.js +156 -0
- package/package.json +40 -0
- package/src/client.js +84 -0
- package/src/crypto.js +14 -0
- package/src/daemon.js +299 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 vikasprogrammer
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# walkie
|
|
2
|
+
|
|
3
|
+
P2P communication for AI agents. No server. No setup. Just talk.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
npm install -g walkie-sh
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## What is this?
|
|
10
|
+
|
|
11
|
+
AI agents are isolated. When two agents need to collaborate, there's no simple way for them to talk directly. Walkie gives them a walkie-talkie — pick a channel, share a secret, and they find each other automatically over the internet.
|
|
12
|
+
|
|
13
|
+
- **No server** — peer-to-peer via Hyperswarm DHT
|
|
14
|
+
- **No setup** — one install, two commands, agents are talking
|
|
15
|
+
- **Works anywhere** — same machine or different continents
|
|
16
|
+
- **Encrypted** — Noise protocol, secure by default
|
|
17
|
+
- **Agent-native** — CLI-first, any agent that runs shell commands can use it
|
|
18
|
+
|
|
19
|
+
## Quick start
|
|
20
|
+
|
|
21
|
+
**Agent A** (on any machine):
|
|
22
|
+
```bash
|
|
23
|
+
walkie create ops-room --secret mysecret
|
|
24
|
+
walkie send ops-room "task complete, results ready"
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
**Agent B** (on any other machine):
|
|
28
|
+
```bash
|
|
29
|
+
walkie join ops-room --secret mysecret
|
|
30
|
+
walkie read ops-room
|
|
31
|
+
# [14:30:05] a1b2c3d4: task complete, results ready
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Commands
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
walkie create <channel> -s <secret> Create a channel
|
|
38
|
+
walkie join <channel> -s <secret> Join a channel
|
|
39
|
+
walkie send <channel> "message" Send a message
|
|
40
|
+
walkie read <channel> Read pending messages
|
|
41
|
+
walkie read <channel> --wait Block until a message arrives
|
|
42
|
+
walkie status Show active channels & peers
|
|
43
|
+
walkie leave <channel> Leave a channel
|
|
44
|
+
walkie stop Stop the daemon
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## How it works
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
Agent A Agent B
|
|
51
|
+
┌─────────────┐ ┌─────────────┐
|
|
52
|
+
│ walkie CLI │ │ walkie CLI │
|
|
53
|
+
│ ↕ │ │ ↕ │
|
|
54
|
+
│ daemon │◄──── P2P ──────────►│ daemon │
|
|
55
|
+
│ (hyperswarm) │ encrypted via DHT │ (hyperswarm) │
|
|
56
|
+
└─────────────┘ └─────────────┘
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
1. Channel name + secret are hashed into a 32-byte topic
|
|
60
|
+
2. Both agents announce/lookup the topic on the Hyperswarm DHT
|
|
61
|
+
3. DHT connects them directly — no relay, no server
|
|
62
|
+
4. All communication is encrypted via the Noise protocol
|
|
63
|
+
5. A background daemon maintains connections so CLI commands are instant
|
|
64
|
+
|
|
65
|
+
## Use cases
|
|
66
|
+
|
|
67
|
+
- **Multi-agent collaboration** — agents coordinate tasks in real-time
|
|
68
|
+
- **Agent delegation** — one agent sends work to another and waits for results
|
|
69
|
+
- **Agent monitoring** — watch what your agents are doing from another terminal
|
|
70
|
+
- **Cross-machine pipelines** — chain agents across different servers
|
|
71
|
+
|
|
72
|
+
## License
|
|
73
|
+
|
|
74
|
+
MIT
|
package/bin/walkie.js
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const { program } = require('commander')
|
|
4
|
+
const { request } = require('../src/client')
|
|
5
|
+
|
|
6
|
+
program
|
|
7
|
+
.name('walkie')
|
|
8
|
+
.description('P2P communication CLI for AI agents')
|
|
9
|
+
.version('0.1.0')
|
|
10
|
+
|
|
11
|
+
program
|
|
12
|
+
.command('create <channel>')
|
|
13
|
+
.description('Create a channel and wait for peers')
|
|
14
|
+
.requiredOption('-s, --secret <secret>', 'Shared secret')
|
|
15
|
+
.action(async (channel, opts) => {
|
|
16
|
+
try {
|
|
17
|
+
const resp = await request({ action: 'join', channel, secret: opts.secret })
|
|
18
|
+
if (resp.ok) {
|
|
19
|
+
console.log(`Channel "${channel}" created. Listening for peers...`)
|
|
20
|
+
} else {
|
|
21
|
+
console.error(`Error: ${resp.error}`)
|
|
22
|
+
process.exit(1)
|
|
23
|
+
}
|
|
24
|
+
} catch (e) {
|
|
25
|
+
console.error(`Error: ${e.message}`)
|
|
26
|
+
process.exit(1)
|
|
27
|
+
}
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
program
|
|
31
|
+
.command('join <channel>')
|
|
32
|
+
.description('Join an existing channel')
|
|
33
|
+
.requiredOption('-s, --secret <secret>', 'Shared secret')
|
|
34
|
+
.action(async (channel, opts) => {
|
|
35
|
+
try {
|
|
36
|
+
const resp = await request({ action: 'join', channel, secret: opts.secret })
|
|
37
|
+
if (resp.ok) {
|
|
38
|
+
console.log(`Joined channel "${channel}"`)
|
|
39
|
+
} else {
|
|
40
|
+
console.error(`Error: ${resp.error}`)
|
|
41
|
+
process.exit(1)
|
|
42
|
+
}
|
|
43
|
+
} catch (e) {
|
|
44
|
+
console.error(`Error: ${e.message}`)
|
|
45
|
+
process.exit(1)
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
program
|
|
50
|
+
.command('send <channel> <message>')
|
|
51
|
+
.description('Send a message to a channel')
|
|
52
|
+
.action(async (channel, message) => {
|
|
53
|
+
try {
|
|
54
|
+
const resp = await request({ action: 'send', channel, message })
|
|
55
|
+
if (resp.ok) {
|
|
56
|
+
console.log(`Sent (delivered to ${resp.delivered} peer${resp.delivered !== 1 ? 's' : ''})`)
|
|
57
|
+
} else {
|
|
58
|
+
console.error(`Error: ${resp.error}`)
|
|
59
|
+
process.exit(1)
|
|
60
|
+
}
|
|
61
|
+
} catch (e) {
|
|
62
|
+
console.error(`Error: ${e.message}`)
|
|
63
|
+
process.exit(1)
|
|
64
|
+
}
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
program
|
|
68
|
+
.command('read <channel>')
|
|
69
|
+
.description('Read pending messages from a channel')
|
|
70
|
+
.option('-w, --wait', 'Block until a message arrives')
|
|
71
|
+
.option('-t, --timeout <seconds>', 'Timeout for --wait in seconds', '30')
|
|
72
|
+
.action(async (channel, opts) => {
|
|
73
|
+
try {
|
|
74
|
+
const cmd = { action: 'read', channel }
|
|
75
|
+
if (opts.wait) {
|
|
76
|
+
cmd.wait = true
|
|
77
|
+
cmd.timeout = parseInt(opts.timeout, 10)
|
|
78
|
+
}
|
|
79
|
+
const timeout = opts.wait ? (parseInt(opts.timeout, 10) + 5) * 1000 : 10000
|
|
80
|
+
const resp = await request(cmd, timeout)
|
|
81
|
+
if (resp.ok) {
|
|
82
|
+
if (resp.messages.length === 0) {
|
|
83
|
+
console.log('No new messages')
|
|
84
|
+
} else {
|
|
85
|
+
for (const msg of resp.messages) {
|
|
86
|
+
const time = new Date(msg.ts).toLocaleTimeString()
|
|
87
|
+
console.log(`[${time}] ${msg.from}: ${msg.data}`)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
} else {
|
|
91
|
+
console.error(`Error: ${resp.error}`)
|
|
92
|
+
process.exit(1)
|
|
93
|
+
}
|
|
94
|
+
} catch (e) {
|
|
95
|
+
console.error(`Error: ${e.message}`)
|
|
96
|
+
process.exit(1)
|
|
97
|
+
}
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
program
|
|
101
|
+
.command('leave <channel>')
|
|
102
|
+
.description('Leave a channel')
|
|
103
|
+
.action(async (channel) => {
|
|
104
|
+
try {
|
|
105
|
+
const resp = await request({ action: 'leave', channel })
|
|
106
|
+
if (resp.ok) {
|
|
107
|
+
console.log(`Left channel "${channel}"`)
|
|
108
|
+
} else {
|
|
109
|
+
console.error(`Error: ${resp.error}`)
|
|
110
|
+
process.exit(1)
|
|
111
|
+
}
|
|
112
|
+
} catch (e) {
|
|
113
|
+
console.error(`Error: ${e.message}`)
|
|
114
|
+
process.exit(1)
|
|
115
|
+
}
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
program
|
|
119
|
+
.command('status')
|
|
120
|
+
.description('Show active channels and peers')
|
|
121
|
+
.action(async () => {
|
|
122
|
+
try {
|
|
123
|
+
const resp = await request({ action: 'status' })
|
|
124
|
+
if (resp.ok) {
|
|
125
|
+
console.log(`Daemon ID: ${resp.daemonId}`)
|
|
126
|
+
const entries = Object.entries(resp.channels)
|
|
127
|
+
if (entries.length === 0) {
|
|
128
|
+
console.log('No active channels')
|
|
129
|
+
} else {
|
|
130
|
+
for (const [name, info] of entries) {
|
|
131
|
+
console.log(` #${name} — ${info.peers} peer(s), ${info.buffered} buffered`)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
} else {
|
|
135
|
+
console.error(`Error: ${resp.error}`)
|
|
136
|
+
process.exit(1)
|
|
137
|
+
}
|
|
138
|
+
} catch (e) {
|
|
139
|
+
console.error(`Error: ${e.message}`)
|
|
140
|
+
process.exit(1)
|
|
141
|
+
}
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
program
|
|
145
|
+
.command('stop')
|
|
146
|
+
.description('Stop the walkie daemon')
|
|
147
|
+
.action(async () => {
|
|
148
|
+
try {
|
|
149
|
+
await request({ action: 'stop' })
|
|
150
|
+
console.log('Daemon stopped')
|
|
151
|
+
} catch {
|
|
152
|
+
console.log('Daemon is not running')
|
|
153
|
+
}
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
program.parse()
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "walkie-sh",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "P2P communication CLI for AI agents. No server. No setup. Just talk.",
|
|
5
|
+
"homepage": "https://walkie.sh",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/vikasprogrammer/walkie.git"
|
|
9
|
+
},
|
|
10
|
+
"bugs": {
|
|
11
|
+
"url": "https://github.com/vikasprogrammer/walkie/issues"
|
|
12
|
+
},
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"author": "vikasprogrammer",
|
|
15
|
+
"keywords": [
|
|
16
|
+
"p2p",
|
|
17
|
+
"agent",
|
|
18
|
+
"ai",
|
|
19
|
+
"communication",
|
|
20
|
+
"walkie-talkie",
|
|
21
|
+
"hyperswarm",
|
|
22
|
+
"cli",
|
|
23
|
+
"llm",
|
|
24
|
+
"multi-agent"
|
|
25
|
+
],
|
|
26
|
+
"bin": {
|
|
27
|
+
"walkie": "./bin/walkie.js"
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"bin/",
|
|
31
|
+
"src/"
|
|
32
|
+
],
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=18"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"commander": "^12.0.0",
|
|
38
|
+
"hyperswarm": "^4.0.0"
|
|
39
|
+
}
|
|
40
|
+
}
|
package/src/client.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
const net = require('net')
|
|
2
|
+
const path = require('path')
|
|
3
|
+
const { spawn } = require('child_process')
|
|
4
|
+
const fs = require('fs')
|
|
5
|
+
|
|
6
|
+
const WALKIE_DIR = process.env.WALKIE_DIR || path.join(process.env.HOME, '.walkie')
|
|
7
|
+
const SOCKET_PATH = path.join(WALKIE_DIR, 'daemon.sock')
|
|
8
|
+
|
|
9
|
+
function connect() {
|
|
10
|
+
return new Promise((resolve, reject) => {
|
|
11
|
+
const sock = net.connect(SOCKET_PATH)
|
|
12
|
+
sock.on('connect', () => resolve(sock))
|
|
13
|
+
sock.on('error', reject)
|
|
14
|
+
})
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function sendCommand(sock, cmd, timeout = 60000) {
|
|
18
|
+
return new Promise((resolve, reject) => {
|
|
19
|
+
let buf = ''
|
|
20
|
+
const timer = setTimeout(() => {
|
|
21
|
+
sock.removeListener('data', onData)
|
|
22
|
+
reject(new Error('Command timed out'))
|
|
23
|
+
}, timeout)
|
|
24
|
+
|
|
25
|
+
const onData = (data) => {
|
|
26
|
+
buf += data.toString()
|
|
27
|
+
const idx = buf.indexOf('\n')
|
|
28
|
+
if (idx !== -1) {
|
|
29
|
+
clearTimeout(timer)
|
|
30
|
+
sock.removeListener('data', onData)
|
|
31
|
+
try {
|
|
32
|
+
resolve(JSON.parse(buf.slice(0, idx)))
|
|
33
|
+
} catch (e) {
|
|
34
|
+
reject(e)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
sock.on('data', onData)
|
|
39
|
+
sock.write(JSON.stringify(cmd) + '\n')
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function ensureDaemon() {
|
|
44
|
+
// Try connecting to existing daemon
|
|
45
|
+
try {
|
|
46
|
+
const sock = await connect()
|
|
47
|
+
const resp = await sendCommand(sock, { action: 'ping' })
|
|
48
|
+
sock.destroy()
|
|
49
|
+
if (resp.ok) return
|
|
50
|
+
} catch {}
|
|
51
|
+
|
|
52
|
+
// Spawn daemon
|
|
53
|
+
fs.mkdirSync(WALKIE_DIR, { recursive: true })
|
|
54
|
+
|
|
55
|
+
const daemonScript = path.join(__dirname, 'daemon.js')
|
|
56
|
+
const child = spawn(process.execPath, [daemonScript], {
|
|
57
|
+
detached: true,
|
|
58
|
+
stdio: 'ignore'
|
|
59
|
+
})
|
|
60
|
+
child.unref()
|
|
61
|
+
|
|
62
|
+
// Poll until ready
|
|
63
|
+
for (let i = 0; i < 50; i++) {
|
|
64
|
+
await new Promise(r => setTimeout(r, 200))
|
|
65
|
+
try {
|
|
66
|
+
const sock = await connect()
|
|
67
|
+
const resp = await sendCommand(sock, { action: 'ping' })
|
|
68
|
+
sock.destroy()
|
|
69
|
+
if (resp.ok) return
|
|
70
|
+
} catch {}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
throw new Error('Failed to start walkie daemon')
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function request(cmd, timeout) {
|
|
77
|
+
await ensureDaemon()
|
|
78
|
+
const sock = await connect()
|
|
79
|
+
const resp = await sendCommand(sock, cmd, timeout)
|
|
80
|
+
sock.destroy()
|
|
81
|
+
return resp
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
module.exports = { request }
|
package/src/crypto.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
const crypto = require('crypto')
|
|
2
|
+
|
|
3
|
+
function deriveTopic(channel, secret) {
|
|
4
|
+
return crypto.createHash('sha256')
|
|
5
|
+
.update(`walkie:${channel}:${secret}`)
|
|
6
|
+
.digest()
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function agentId() {
|
|
10
|
+
// Generate a short random ID for this daemon instance
|
|
11
|
+
return crypto.randomBytes(4).toString('hex')
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
module.exports = { deriveTopic, agentId }
|
package/src/daemon.js
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
const Hyperswarm = require('hyperswarm')
|
|
2
|
+
const net = require('net')
|
|
3
|
+
const fs = require('fs')
|
|
4
|
+
const path = require('path')
|
|
5
|
+
const { deriveTopic, agentId } = require('./crypto')
|
|
6
|
+
|
|
7
|
+
const WALKIE_DIR = process.env.WALKIE_DIR || path.join(process.env.HOME, '.walkie')
|
|
8
|
+
const SOCKET_PATH = path.join(WALKIE_DIR, 'daemon.sock')
|
|
9
|
+
const PID_FILE = path.join(WALKIE_DIR, 'daemon.pid')
|
|
10
|
+
const LOG_FILE = path.join(WALKIE_DIR, 'daemon.log')
|
|
11
|
+
|
|
12
|
+
function log(...args) {
|
|
13
|
+
const line = `[${new Date().toISOString()}] ${args.join(' ')}\n`
|
|
14
|
+
try { fs.appendFileSync(LOG_FILE, line) } catch {}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
class WalkieDaemon {
|
|
18
|
+
constructor() {
|
|
19
|
+
this.id = agentId()
|
|
20
|
+
this.swarm = new Hyperswarm()
|
|
21
|
+
this.channels = new Map() // name -> { topicHex, discovery, peers: Set, messages: [], waiters: [] }
|
|
22
|
+
this.peers = new Map() // remoteKey hex -> { conn, channels: Set }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async start() {
|
|
26
|
+
fs.mkdirSync(WALKIE_DIR, { recursive: true })
|
|
27
|
+
fs.writeFileSync(PID_FILE, process.pid.toString())
|
|
28
|
+
|
|
29
|
+
// Clean stale socket
|
|
30
|
+
try { fs.unlinkSync(SOCKET_PATH) } catch {}
|
|
31
|
+
|
|
32
|
+
// IPC server for CLI commands
|
|
33
|
+
const server = net.createServer(sock => this._onIPC(sock))
|
|
34
|
+
server.listen(SOCKET_PATH)
|
|
35
|
+
|
|
36
|
+
// P2P connections
|
|
37
|
+
this.swarm.on('connection', (conn, info) => this._onPeer(conn, info))
|
|
38
|
+
|
|
39
|
+
process.on('SIGTERM', () => this.shutdown())
|
|
40
|
+
process.on('SIGINT', () => this.shutdown())
|
|
41
|
+
|
|
42
|
+
log(`Daemon started id=${this.id} pid=${process.pid}`)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ── IPC (CLI <-> Daemon) ──────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
_onIPC(socket) {
|
|
48
|
+
let buf = ''
|
|
49
|
+
socket.on('data', data => {
|
|
50
|
+
buf += data.toString()
|
|
51
|
+
let idx
|
|
52
|
+
while ((idx = buf.indexOf('\n')) !== -1) {
|
|
53
|
+
const line = buf.slice(0, idx)
|
|
54
|
+
buf = buf.slice(idx + 1)
|
|
55
|
+
if (line.trim()) {
|
|
56
|
+
try {
|
|
57
|
+
this._exec(JSON.parse(line), socket)
|
|
58
|
+
} catch (e) {
|
|
59
|
+
socket.write(JSON.stringify({ ok: false, error: e.message }) + '\n')
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
})
|
|
64
|
+
socket.on('error', () => {})
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async _exec(cmd, socket) {
|
|
68
|
+
const reply = d => socket.write(JSON.stringify(d) + '\n')
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
switch (cmd.action) {
|
|
72
|
+
case 'join': {
|
|
73
|
+
await this._joinChannel(cmd.channel, cmd.secret)
|
|
74
|
+
reply({ ok: true, channel: cmd.channel })
|
|
75
|
+
break
|
|
76
|
+
}
|
|
77
|
+
case 'send': {
|
|
78
|
+
const count = this._send(cmd.channel, cmd.message)
|
|
79
|
+
reply({ ok: true, delivered: count })
|
|
80
|
+
break
|
|
81
|
+
}
|
|
82
|
+
case 'read': {
|
|
83
|
+
const ch = this.channels.get(cmd.channel)
|
|
84
|
+
if (!ch) { reply({ ok: false, error: `Not in channel: ${cmd.channel}` }); return }
|
|
85
|
+
|
|
86
|
+
// If messages available or no wait requested, return immediately
|
|
87
|
+
if (ch.messages.length > 0 || !cmd.wait) {
|
|
88
|
+
reply({ ok: true, messages: ch.messages.splice(0) })
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Wait mode: hold connection until a message arrives or timeout
|
|
93
|
+
const timeout = (cmd.timeout || 30) * 1000
|
|
94
|
+
const timer = setTimeout(() => {
|
|
95
|
+
ch.waiters = ch.waiters.filter(w => w !== waiter)
|
|
96
|
+
reply({ ok: true, messages: [] })
|
|
97
|
+
}, timeout)
|
|
98
|
+
|
|
99
|
+
const waiter = (msgs) => {
|
|
100
|
+
clearTimeout(timer)
|
|
101
|
+
reply({ ok: true, messages: msgs })
|
|
102
|
+
}
|
|
103
|
+
ch.waiters.push(waiter)
|
|
104
|
+
break
|
|
105
|
+
}
|
|
106
|
+
case 'leave': {
|
|
107
|
+
await this._leaveChannel(cmd.channel)
|
|
108
|
+
reply({ ok: true })
|
|
109
|
+
break
|
|
110
|
+
}
|
|
111
|
+
case 'status': {
|
|
112
|
+
const channels = {}
|
|
113
|
+
for (const [name, ch] of this.channels) {
|
|
114
|
+
channels[name] = { peers: ch.peers.size, buffered: ch.messages.length }
|
|
115
|
+
}
|
|
116
|
+
reply({ ok: true, channels, daemonId: this.id })
|
|
117
|
+
break
|
|
118
|
+
}
|
|
119
|
+
case 'ping': {
|
|
120
|
+
reply({ ok: true })
|
|
121
|
+
break
|
|
122
|
+
}
|
|
123
|
+
case 'stop': {
|
|
124
|
+
reply({ ok: true })
|
|
125
|
+
await this.shutdown()
|
|
126
|
+
break
|
|
127
|
+
}
|
|
128
|
+
default:
|
|
129
|
+
reply({ ok: false, error: `Unknown action: ${cmd.action}` })
|
|
130
|
+
}
|
|
131
|
+
} catch (e) {
|
|
132
|
+
reply({ ok: false, error: e.message })
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ── Channel management ────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
async _joinChannel(name, secret) {
|
|
139
|
+
if (this.channels.has(name)) return
|
|
140
|
+
|
|
141
|
+
const topic = deriveTopic(name, secret)
|
|
142
|
+
const topicHex = topic.toString('hex')
|
|
143
|
+
log(`Joining channel "${name}" topic=${topicHex.slice(0, 16)}...`)
|
|
144
|
+
const discovery = this.swarm.join(topic, { server: true, client: true })
|
|
145
|
+
await discovery.flushed()
|
|
146
|
+
log(`Channel "${name}" flushed, discoverable`)
|
|
147
|
+
|
|
148
|
+
this.channels.set(name, {
|
|
149
|
+
topicHex,
|
|
150
|
+
discovery,
|
|
151
|
+
peers: new Set(),
|
|
152
|
+
messages: [],
|
|
153
|
+
waiters: []
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
// Re-announce topics to already-connected peers (fixes race condition
|
|
157
|
+
// where peer connects before channel is registered)
|
|
158
|
+
this._reannounce()
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
_reannounce() {
|
|
162
|
+
const topics = Array.from(this.channels.values()).map(ch => ch.topicHex)
|
|
163
|
+
const hello = JSON.stringify({ t: 'hello', topics, id: this.id }) + '\n'
|
|
164
|
+
for (const [remoteKey, peer] of this.peers) {
|
|
165
|
+
if (peer.conn?.writable) {
|
|
166
|
+
log(`Re-announcing ${topics.length} topic(s) to ${remoteKey.slice(0, 12)}`)
|
|
167
|
+
peer.conn.write(hello)
|
|
168
|
+
}
|
|
169
|
+
// Also match this peer against our newly added channels
|
|
170
|
+
// (handles case where we received their hello before our channel was ready)
|
|
171
|
+
if (peer.knownTopics) {
|
|
172
|
+
for (const [name, ch] of this.channels) {
|
|
173
|
+
if (peer.knownTopics.has(ch.topicHex) && !ch.peers.has(remoteKey)) {
|
|
174
|
+
ch.peers.add(remoteKey)
|
|
175
|
+
peer.channels.add(name)
|
|
176
|
+
log(`Late-matched channel "${name}" with peer ${remoteKey.slice(0, 12)}`)
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async _leaveChannel(name) {
|
|
184
|
+
const ch = this.channels.get(name)
|
|
185
|
+
if (!ch) return
|
|
186
|
+
await ch.discovery.destroy()
|
|
187
|
+
this.channels.delete(name)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ── P2P peer handling ─────────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
_onPeer(conn, info) {
|
|
193
|
+
const remoteKey = conn.remotePublicKey.toString('hex')
|
|
194
|
+
log(`Peer connected: ${remoteKey.slice(0, 12)}...`)
|
|
195
|
+
|
|
196
|
+
const peer = { conn, channels: new Set(), buf: '' }
|
|
197
|
+
this.peers.set(remoteKey, peer)
|
|
198
|
+
|
|
199
|
+
// Send handshake: our active topic list
|
|
200
|
+
const topics = Array.from(this.channels.values()).map(ch => ch.topicHex)
|
|
201
|
+
log(`Sending hello with ${topics.length} topic(s)`)
|
|
202
|
+
conn.write(JSON.stringify({ t: 'hello', topics, id: this.id }) + '\n')
|
|
203
|
+
|
|
204
|
+
conn.on('data', data => {
|
|
205
|
+
peer.buf += data.toString()
|
|
206
|
+
let idx
|
|
207
|
+
while ((idx = peer.buf.indexOf('\n')) !== -1) {
|
|
208
|
+
const line = peer.buf.slice(0, idx)
|
|
209
|
+
peer.buf = peer.buf.slice(idx + 1)
|
|
210
|
+
if (line.trim()) {
|
|
211
|
+
try { this._onPeerMsg(remoteKey, JSON.parse(line)) } catch {}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
conn.on('close', () => {
|
|
217
|
+
for (const [, ch] of this.channels) ch.peers.delete(remoteKey)
|
|
218
|
+
this.peers.delete(remoteKey)
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
conn.on('error', () => conn.destroy())
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
_onPeerMsg(remoteKey, msg) {
|
|
225
|
+
const peer = this.peers.get(remoteKey)
|
|
226
|
+
if (!peer) return
|
|
227
|
+
|
|
228
|
+
if (msg.t === 'hello') {
|
|
229
|
+
const theirTopics = new Set(msg.topics || [])
|
|
230
|
+
peer.knownTopics = theirTopics // Store for late-matching
|
|
231
|
+
log(`Got hello from ${remoteKey.slice(0, 12)} with ${theirTopics.size} topic(s)`)
|
|
232
|
+
for (const [name, ch] of this.channels) {
|
|
233
|
+
if (theirTopics.has(ch.topicHex) && !ch.peers.has(remoteKey)) {
|
|
234
|
+
ch.peers.add(remoteKey)
|
|
235
|
+
peer.channels.add(name)
|
|
236
|
+
log(`Matched channel "${name}" with peer ${remoteKey.slice(0, 12)}`)
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (msg.t === 'msg') {
|
|
243
|
+
for (const [name, ch] of this.channels) {
|
|
244
|
+
if (ch.topicHex === msg.topic) {
|
|
245
|
+
const entry = { from: msg.id || remoteKey.slice(0, 8), data: msg.data, ts: msg.ts }
|
|
246
|
+
|
|
247
|
+
// If someone is waiting, deliver directly
|
|
248
|
+
if (ch.waiters.length > 0) {
|
|
249
|
+
const waiter = ch.waiters.shift()
|
|
250
|
+
waiter([entry])
|
|
251
|
+
} else {
|
|
252
|
+
ch.messages.push(entry)
|
|
253
|
+
}
|
|
254
|
+
break
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ── Send ──────────────────────────────────────────────────────────
|
|
261
|
+
|
|
262
|
+
_send(channelName, message) {
|
|
263
|
+
const ch = this.channels.get(channelName)
|
|
264
|
+
if (!ch) throw new Error(`Not in channel: ${channelName}`)
|
|
265
|
+
|
|
266
|
+
const payload = JSON.stringify({
|
|
267
|
+
t: 'msg',
|
|
268
|
+
topic: ch.topicHex,
|
|
269
|
+
data: message,
|
|
270
|
+
id: this.id,
|
|
271
|
+
ts: Date.now()
|
|
272
|
+
}) + '\n'
|
|
273
|
+
|
|
274
|
+
let count = 0
|
|
275
|
+
for (const remoteKey of ch.peers) {
|
|
276
|
+
const peer = this.peers.get(remoteKey)
|
|
277
|
+
if (peer?.conn?.writable) {
|
|
278
|
+
peer.conn.write(payload)
|
|
279
|
+
count++
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return count
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ── Shutdown ──────────────────────────────────────────────────────
|
|
286
|
+
|
|
287
|
+
async shutdown() {
|
|
288
|
+
try { fs.unlinkSync(SOCKET_PATH) } catch {}
|
|
289
|
+
try { fs.unlinkSync(PID_FILE) } catch {}
|
|
290
|
+
await this.swarm.destroy()
|
|
291
|
+
process.exit(0)
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const daemon = new WalkieDaemon()
|
|
296
|
+
daemon.start().catch(e => {
|
|
297
|
+
console.error('Failed to start daemon:', e.message)
|
|
298
|
+
process.exit(1)
|
|
299
|
+
})
|