clawnexus 0.2.5 → 0.2.6

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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Alan Lan
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 CHANGED
@@ -1,108 +1,129 @@
1
- # clawnexus
2
-
3
- ClawNexus daemon and CLI — AI instance registry for OpenClaw.
4
-
5
- Discovers OpenClaw instances on your local network, assigns human-readable aliases, and exposes an HTTP API for querying and managing them.
6
-
7
- ## Installation
8
-
9
- ```bash
10
- npm install -g clawnexus
11
- ```
12
-
13
- Requires Node.js >= 22.
14
-
15
- ## CLI Usage
16
-
17
- ### Daemon Management
18
-
19
- ```bash
20
- clawnexus start # Start the daemon (background process)
21
- clawnexus stop # Stop the daemon
22
- clawnexus restart # Restart the daemon
23
- clawnexus status # Show daemon status
24
- ```
25
-
26
- ### Instance Discovery
27
-
28
- ```bash
29
- clawnexus scan # Scan local network for OpenClaw instances
30
- clawnexus list # List all known instances
31
- clawnexus list --json # Machine-readable JSON output
32
- ```
33
-
34
- ### Instance Management
35
-
36
- ```bash
37
- clawnexus alias <id|address> <name> # Set a friendly alias
38
- clawnexus info <name|address> # Show instance details
39
- clawnexus forget <name|address> # Remove from registry
40
- ```
41
-
42
- ### Connection
43
-
44
- ```bash
45
- clawnexus connect <name> # Output ws:// address for an instance
46
- clawnexus open <name> # Open WebChat UI in browser
47
- clawnexus relay status # Show relay connection status
48
- clawnexus connect <name.claw> # Connect via relay (v0.4)
49
- ```
50
-
51
- ### Global Flags
52
-
53
- | Flag | Description | Default |
54
- |------|-------------|---------|
55
- | `--json` | Machine-readable JSON output | `false` |
56
- | `--timeout <ms>` | Request timeout | `5000` |
57
- | `--api <url>` | Daemon API URL | `http://localhost:17890` |
58
-
59
- ## Daemon HTTP API
60
-
61
- The daemon listens on `http://localhost:17890` by default.
62
-
63
- | Method | Path | Description |
64
- |--------|------|-------------|
65
- | `GET` | `/health` | Daemon health status |
66
- | `GET` | `/instances` | List all instances |
67
- | `GET` | `/instances/:id` | Get a single instance |
68
- | `PUT` | `/instances/:id/alias` | Set/update alias |
69
- | `DELETE` | `/instances/:id` | Remove instance |
70
- | `POST` | `/scan` | Trigger network scan |
71
- | `POST` | `/relay/connect` | Connect via relay (v0.4) |
72
- | `GET` | `/relay/status` | Relay connection status |
73
- | `DELETE` | `/relay/disconnect/:room_id` | Disconnect relay room |
74
-
75
- See [docs/api.md](../../docs/api.md) for full request/response examples.
76
-
77
- ## Configuration
78
-
79
- | Environment Variable | Description | Default |
80
- |---------------------|-------------|---------|
81
- | `CLAWNEXUS_PORT` | Daemon API port | `17890` |
82
- | `CLAWNEXUS_HOST` | Daemon bind address | `127.0.0.1` |
83
- | `CLAWNEXUS_API` | CLI target API URL | `http://localhost:17890` |
84
-
85
- Data is stored in `~/.clawnexus/`:
86
-
87
- ```
88
- ~/.clawnexus/
89
- ├── registry.json # Instance registry
90
- ├── daemon.pid # PID file
91
- └── policy.json # Agent policy (v1.0)
92
- ```
93
-
94
- ## Programmatic Usage
95
-
96
- ```typescript
97
- import { startDaemon } from "clawnexus";
98
-
99
- const handle = await startDaemon({ port: 17890, host: "127.0.0.1" });
100
-
101
- // Access components
102
- console.log(handle.store.getAll()); // List instances
103
- await handle.app.close(); // Graceful shutdown
104
- ```
105
-
106
- ## License
107
-
108
- MIT
1
+ # clawnexus
2
+
3
+ ClawNexus daemon and CLI — AI instance registry for OpenClaw.
4
+
5
+ Discovers OpenClaw instances on your local network, assigns human-readable aliases, and exposes an HTTP API for querying and managing them.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ npm install -g clawnexus
11
+ ```
12
+
13
+ Requires Node.js >= 22.
14
+
15
+ ## CLI Usage
16
+
17
+ ### Daemon Management
18
+
19
+ ```bash
20
+ clawnexus start # Start the daemon (background process)
21
+ clawnexus stop # Stop the daemon
22
+ clawnexus restart # Restart the daemon
23
+ clawnexus status # Show daemon status
24
+ ```
25
+
26
+ ### Instance Discovery
27
+
28
+ ```bash
29
+ clawnexus scan # Scan local network for OpenClaw instances
30
+ clawnexus list # List all known instances
31
+ clawnexus list --json # Machine-readable JSON output
32
+ ```
33
+
34
+ ### Instance Management
35
+
36
+ ```bash
37
+ clawnexus alias <id|address> <name> # Set a friendly alias
38
+ clawnexus info <name|address> # Show instance details
39
+ clawnexus forget <name|address> # Remove from registry
40
+ ```
41
+
42
+ ### Connection
43
+
44
+ ```bash
45
+ clawnexus connect <name> # Output ws:// address for an instance
46
+ clawnexus open <name> # Open WebChat UI in browser
47
+ clawnexus relay status # Show relay connection status
48
+ clawnexus connect <name.claw> # Connect via relay (v0.4)
49
+ ```
50
+
51
+ ### Global Flags
52
+
53
+ | Flag | Description | Default |
54
+ |------|-------------|---------|
55
+ | `--json` | Machine-readable JSON output | `false` |
56
+ | `--timeout <ms>` | Request timeout | `5000` |
57
+ | `--api <url>` | Daemon API URL | `http://localhost:17890` |
58
+
59
+ ## Daemon HTTP API
60
+
61
+ The daemon listens on `http://localhost:17890` by default.
62
+
63
+ | Method | Path | Description |
64
+ |--------|------|-------------|
65
+ | `GET` | `/health` | Daemon health status |
66
+ | `GET` | `/instances` | List all instances |
67
+ | `GET` | `/instances/:id` | Get a single instance |
68
+ | `PUT` | `/instances/:id/alias` | Set/update alias |
69
+ | `DELETE` | `/instances/:id` | Remove instance |
70
+ | `POST` | `/scan` | Trigger network scan |
71
+ | `POST` | `/registry/register` | Register with public Registry (v0.2) |
72
+ | `GET` | `/registry/status` | Registration status (v0.2) |
73
+ | `GET` | `/resolve/:name` | Resolve a `.claw` name (v0.2) |
74
+ | `GET` | `/whoami` | This instance's identity (v0.2) |
75
+ | `POST` | `/relay/connect` | Connect via relay (v0.4) |
76
+ | `GET` | `/relay/status` | Relay connection status (v0.4) |
77
+ | `DELETE` | `/relay/disconnect/:room_id` | Disconnect relay room (v0.4) |
78
+ | `GET` | `/agent/policy` | Get agent policy (v0.4) |
79
+ | `PUT` | `/agent/policy` | Replace agent policy (v0.4) |
80
+ | `PATCH` | `/agent/policy` | Partial policy update (v0.4) |
81
+ | `POST` | `/agent/policy/reset` | Reset policy to defaults (v0.4) |
82
+ | `GET` | `/agent/tasks` | List tasks (v0.4) |
83
+ | `GET` | `/agent/tasks/stats` | Task statistics (v0.4) |
84
+ | `GET` | `/agent/tasks/:id` | Get a single task (v0.4) |
85
+ | `POST` | `/agent/tasks/:id/cancel` | Cancel a task (v0.4) |
86
+ | `POST` | `/agent/propose` | Send task proposal to peer (v0.4) |
87
+ | `POST` | `/agent/query` | Query peer capabilities (v0.4) |
88
+ | `GET` | `/agent/inbox` | List queued inbound proposals (v0.4) |
89
+ | `POST` | `/agent/inbox/:id/approve` | Approve queued proposal (v0.4) |
90
+ | `POST` | `/agent/inbox/:id/deny` | Deny queued proposal (v0.4) |
91
+ | `GET` | `/diagnostics` | Full diagnostics summary |
92
+ | `GET` | `/diagnostics/unreachable` | mDNS-heard but unreachable instances |
93
+
94
+ See [docs/api.md](../../docs/api.md) for full request/response examples.
95
+
96
+ ## Configuration
97
+
98
+ | Environment Variable | Description | Default |
99
+ |---------------------|-------------|---------|
100
+ | `CLAWNEXUS_PORT` | Daemon API port | `17890` |
101
+ | `CLAWNEXUS_HOST` | Daemon bind address | `127.0.0.1` |
102
+ | `CLAWNEXUS_API` | CLI target API URL | `http://localhost:17890` |
103
+ | `CLAWNEXUS_RELAY_URL` | Override relay WebSocket URL | _(from Registry token)_ |
104
+
105
+ Data is stored in `~/.clawnexus/`:
106
+
107
+ ```
108
+ ~/.clawnexus/
109
+ ├── registry.json # Instance registry
110
+ ├── daemon.pid # PID file
111
+ ├── identity.json # Ed25519 identity keys (v0.2)
112
+ └── policy.json # Agent policy configuration (v0.4)
113
+ ```
114
+
115
+ ## Programmatic Usage
116
+
117
+ ```typescript
118
+ import { startDaemon } from "clawnexus";
119
+
120
+ const handle = await startDaemon({ port: 17890, host: "127.0.0.1" });
121
+
122
+ // Access components
123
+ console.log(handle.store.getAll()); // List instances
124
+ await handle.app.close(); // Graceful shutdown
125
+ ```
126
+
127
+ ## License
128
+
129
+ MIT
package/dist/cli/index.js CHANGED
File without changes
@@ -26,6 +26,21 @@ export declare class RegistryStore extends EventEmitter {
26
26
  remove(networkKey: string): boolean;
27
27
  setAlias(networkKey: string, alias: string): void;
28
28
  get size(): number;
29
+ /**
30
+ * Find an existing instance that represents the same physical machine
31
+ * discovered via a different network interface. Matches on agent_id + lan_host.
32
+ */
33
+ private _findDuplicate;
34
+ /**
35
+ * Merge two instances representing the same machine on different NICs.
36
+ * Keeps user-set fields (alias, labels, auto_name, claw_name, owner_pubkey)
37
+ * from the existing entry. Picks the best address by network scope priority.
38
+ */
39
+ private _mergeInstances;
40
+ /** Numeric priority for network scope: local > vpn > public */
41
+ private _addressPriority;
42
+ /** Normalize hostname for comparison: lowercase, strip trailing .local */
43
+ private _normalizeHost;
29
44
  private scheduleDirtyFlush;
30
45
  private flushNow;
31
46
  }
@@ -201,8 +201,25 @@ class RegistryStore extends node_events_1.EventEmitter {
201
201
  // Preserve registry fields
202
202
  instance.claw_name = instance.claw_name ?? existing.claw_name;
203
203
  instance.owner_pubkey = instance.owner_pubkey ?? existing.owner_pubkey;
204
+ this.instances.set(key, instance);
205
+ this.scheduleDirtyFlush();
206
+ this.emit("upsert", instance);
204
207
  }
205
208
  else {
209
+ // Check for duplicate from different NIC (multi-NIC deduplication)
210
+ const duplicate = this._findDuplicate(instance);
211
+ if (duplicate) {
212
+ const merged = this._mergeInstances(duplicate, instance);
213
+ const oldKey = this.networkKey(duplicate.address, duplicate.gateway_port);
214
+ const newKey = this.networkKey(merged.address, merged.gateway_port);
215
+ if (oldKey !== newKey) {
216
+ this.instances.delete(oldKey);
217
+ }
218
+ this.instances.set(newKey, merged);
219
+ this.scheduleDirtyFlush();
220
+ this.emit("merge", { kept: merged, removed_key: oldKey !== newKey ? oldKey : null });
221
+ return;
222
+ }
206
223
  // First discovery: generate auto_name if not already set
207
224
  if (!instance.auto_name) {
208
225
  const baseName = (0, auto_name_js_1.generateAutoName)(instance.lan_host, instance.display_name, instance.address);
@@ -212,10 +229,10 @@ class RegistryStore extends node_events_1.EventEmitter {
212
229
  }
213
230
  instance.auto_name = (0, auto_name_js_1.ensureUnique)(baseName, usedNames);
214
231
  }
232
+ this.instances.set(key, instance);
233
+ this.scheduleDirtyFlush();
234
+ this.emit("upsert", instance);
215
235
  }
216
- this.instances.set(key, instance);
217
- this.scheduleDirtyFlush();
218
- this.emit("upsert", instance);
219
236
  }
220
237
  remove(networkKey) {
221
238
  const deleted = this.instances.delete(networkKey);
@@ -246,6 +263,72 @@ class RegistryStore extends node_events_1.EventEmitter {
246
263
  get size() {
247
264
  return this.instances.size;
248
265
  }
266
+ /**
267
+ * Find an existing instance that represents the same physical machine
268
+ * discovered via a different network interface. Matches on agent_id + lan_host.
269
+ */
270
+ _findDuplicate(instance) {
271
+ const incomingHost = this._normalizeHost(instance.lan_host);
272
+ const incomingAgent = instance.agent_id.toLowerCase();
273
+ for (const existing of this.instances.values()) {
274
+ if (existing.agent_id.toLowerCase() === incomingAgent &&
275
+ this._normalizeHost(existing.lan_host) === incomingHost) {
276
+ return existing;
277
+ }
278
+ }
279
+ return undefined;
280
+ }
281
+ /**
282
+ * Merge two instances representing the same machine on different NICs.
283
+ * Keeps user-set fields (alias, labels, auto_name, claw_name, owner_pubkey)
284
+ * from the existing entry. Picks the best address by network scope priority.
285
+ */
286
+ _mergeInstances(existing, incoming) {
287
+ const existingPriority = this._addressPriority(existing.network_scope);
288
+ const incomingPriority = this._addressPriority(incoming.network_scope);
289
+ // Pick address: higher priority wins; if equal, keep existing (first-seen)
290
+ const keepExistingAddress = existingPriority >= incomingPriority;
291
+ return {
292
+ // Network identity: use the better address
293
+ address: keepExistingAddress ? existing.address : incoming.address,
294
+ gateway_port: keepExistingAddress ? existing.gateway_port : incoming.gateway_port,
295
+ network_scope: keepExistingAddress ? existing.network_scope : incoming.network_scope,
296
+ tls: keepExistingAddress ? existing.tls : incoming.tls,
297
+ tls_fingerprint: keepExistingAddress ? existing.tls_fingerprint : incoming.tls_fingerprint,
298
+ // Identity fields from existing (stable)
299
+ agent_id: existing.agent_id,
300
+ auto_name: existing.auto_name,
301
+ alias: existing.alias,
302
+ lan_host: existing.lan_host,
303
+ assistant_name: incoming.assistant_name,
304
+ display_name: incoming.display_name,
305
+ // Timestamps: keep original discovered_at, use latest last_seen
306
+ discovered_at: existing.discovered_at,
307
+ last_seen: incoming.last_seen,
308
+ // Discovery source: use the incoming one (most recent discovery)
309
+ discovery_source: incoming.discovery_source,
310
+ // Status from incoming (freshest)
311
+ status: incoming.status,
312
+ // Preserve user-set and registry fields from existing
313
+ claw_name: existing.claw_name ?? incoming.claw_name,
314
+ owner_pubkey: existing.owner_pubkey ?? incoming.owner_pubkey,
315
+ labels: existing.labels ?? incoming.labels,
316
+ connectivity: incoming.connectivity ?? existing.connectivity,
317
+ is_self: existing.is_self || incoming.is_self,
318
+ };
319
+ }
320
+ /** Numeric priority for network scope: local > vpn > public */
321
+ _addressPriority(scope) {
322
+ switch (scope) {
323
+ case "local": return 2;
324
+ case "vpn": return 1;
325
+ case "public": return 0;
326
+ }
327
+ }
328
+ /** Normalize hostname for comparison: lowercase, strip trailing .local */
329
+ _normalizeHost(host) {
330
+ return host.toLowerCase().replace(/\.local$/, "");
331
+ }
249
332
  scheduleDirtyFlush() {
250
333
  this.dirty = true;
251
334
  if (this.flushTimer)
@@ -18,4 +18,13 @@ export declare class ActiveScanner extends EventEmitter {
18
18
  private generateIPs;
19
19
  private probeAll;
20
20
  private probeHost;
21
+ /**
22
+ * Resolve a stable lan_host for scanned instances to enable multi-NIC dedup.
23
+ *
24
+ * When scanning, we only have the target IP as lan_host. If the same instance
25
+ * was already discovered via mDNS or LocalProbe (which provide real hostnames),
26
+ * reuse that hostname so _findDuplicate() can match them as the same machine.
27
+ */
28
+ private resolveHostForDedup;
29
+ private isIPAddress;
21
30
  }
@@ -170,13 +170,16 @@ class ActiveScanner extends node_events_1.EventEmitter {
170
170
  const config = (await res.json());
171
171
  if (!config.assistantAgentId)
172
172
  return null;
173
+ // Resolve lan_host: prefer known hostname from existing registry entries
174
+ // over raw IP to enable multi-NIC deduplication
175
+ const lan_host = this.resolveHostForDedup(host, port, config.assistantAgentId);
173
176
  const now = new Date().toISOString();
174
177
  return {
175
178
  agent_id: config.assistantAgentId,
176
179
  auto_name: "", // will be assigned by store.upsert()
177
180
  assistant_name: config.assistantName ?? "",
178
181
  display_name: config.displayName ?? config.assistantName ?? "",
179
- lan_host: host,
182
+ lan_host,
180
183
  address: host,
181
184
  gateway_port: port,
182
185
  tls: false,
@@ -191,6 +194,29 @@ class ActiveScanner extends node_events_1.EventEmitter {
191
194
  return null;
192
195
  }
193
196
  }
197
+ /**
198
+ * Resolve a stable lan_host for scanned instances to enable multi-NIC dedup.
199
+ *
200
+ * When scanning, we only have the target IP as lan_host. If the same instance
201
+ * was already discovered via mDNS or LocalProbe (which provide real hostnames),
202
+ * reuse that hostname so _findDuplicate() can match them as the same machine.
203
+ */
204
+ resolveHostForDedup(host, port, agentId) {
205
+ if (!this.isIPAddress(host))
206
+ return host;
207
+ const agentLower = agentId.toLowerCase();
208
+ for (const existing of this.store.getAll()) {
209
+ if (existing.agent_id.toLowerCase() === agentLower &&
210
+ existing.gateway_port === port &&
211
+ !this.isIPAddress(existing.lan_host)) {
212
+ return existing.lan_host;
213
+ }
214
+ }
215
+ return host;
216
+ }
217
+ isIPAddress(value) {
218
+ return /^\d{1,3}(\.\d{1,3}){3}$/.test(value);
219
+ }
194
220
  }
195
221
  exports.ActiveScanner = ActiveScanner;
196
222
  /** Parse "host:port" or "host" into { host, port } */
package/package.json CHANGED
@@ -1,54 +1,53 @@
1
- {
2
- "name": "clawnexus",
3
- "version": "0.2.5",
4
- "description": "ClawNexus daemon and CLI — AI instance registry for OpenClaw",
5
- "license": "MIT",
6
- "author": "alan-silverstreams <alan@silverstream.tech>",
7
- "homepage": "https://github.com/alan-silverstreams/ClawNexus",
8
- "repository": {
9
- "type": "git",
10
- "url": "git+https://github.com/alan-silverstreams/ClawNexus.git",
11
- "directory": "packages/daemon"
12
- },
13
- "bugs": "https://github.com/alan-silverstreams/ClawNexus/issues",
14
- "keywords": [
15
- "openclaw",
16
- "ai",
17
- "agent",
18
- "registry",
19
- "mdns",
20
- "discovery",
21
- "daemon",
22
- "cli",
23
- "clawnexus"
24
- ],
25
- "main": "dist/index.js",
26
- "types": "dist/index.d.ts",
27
- "bin": {
28
- "clawnexus": "dist/cli/index.js"
29
- },
30
- "files": [
31
- "dist",
32
- "README.md"
33
- ],
34
- "scripts": {
35
- "build": "tsc -p tsconfig.json",
36
- "dev": "tsc -p tsconfig.json --watch",
37
- "start": "node dist/index.js",
38
- "test": "vitest run",
39
- "prepublishOnly": "pnpm build"
40
- },
41
- "engines": {
42
- "node": ">=22"
43
- },
44
- "dependencies": {
45
- "fastify": "^5.0.0",
46
- "multicast-dns": "^7.2.5",
47
- "ws": "^8.18.0"
48
- },
49
- "devDependencies": {
50
- "typescript": "^5.7.0",
51
- "@types/node": "^22.0.0",
52
- "@types/ws": "^8.5.14"
53
- }
54
- }
1
+ {
2
+ "name": "clawnexus",
3
+ "version": "0.2.6",
4
+ "description": "ClawNexus daemon and CLI — AI instance registry for OpenClaw",
5
+ "license": "MIT",
6
+ "author": "alan-silverstreams <alan@silverstream.tech>",
7
+ "homepage": "https://github.com/SilverstreamsAI/ClawNexus",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/SilverstreamsAI/ClawNexus.git",
11
+ "directory": "packages/daemon"
12
+ },
13
+ "bugs": "https://github.com/SilverstreamsAI/ClawNexus/issues",
14
+ "keywords": [
15
+ "openclaw",
16
+ "ai",
17
+ "agent",
18
+ "registry",
19
+ "mdns",
20
+ "discovery",
21
+ "daemon",
22
+ "cli",
23
+ "clawnexus"
24
+ ],
25
+ "main": "dist/index.js",
26
+ "types": "dist/index.d.ts",
27
+ "bin": {
28
+ "clawnexus": "dist/cli/index.js"
29
+ },
30
+ "files": [
31
+ "dist",
32
+ "README.md"
33
+ ],
34
+ "engines": {
35
+ "node": ">=22"
36
+ },
37
+ "dependencies": {
38
+ "fastify": "^5.0.0",
39
+ "multicast-dns": "^7.2.5",
40
+ "ws": "^8.18.0"
41
+ },
42
+ "devDependencies": {
43
+ "typescript": "^5.7.0",
44
+ "@types/node": "^22.0.0",
45
+ "@types/ws": "^8.5.14"
46
+ },
47
+ "scripts": {
48
+ "build": "tsc -p tsconfig.json",
49
+ "dev": "tsc -p tsconfig.json --watch",
50
+ "start": "node dist/index.js",
51
+ "test": "vitest run"
52
+ }
53
+ }