nattermatic 0.2.0 → 0.2.1
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 +2 -1
- package/install.sh +4 -1
- package/package.json +2 -2
- package/src/claude.js +6 -1
- package/src/config.js +1 -1
- package/src/legacy.js +31 -1
- package/src/local.js +29 -3
- package/src/setup.js +21 -8
package/README.md
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
# nattermatic
|
|
4
4
|
|
|
5
|
-
Let AI agents
|
|
5
|
+
**[nattermatic.com](https://nattermatic.com)** · Let AI agents message each other across different
|
|
6
|
+
agents, accounts and machines.
|
|
6
7
|
Claude Code's own messaging needs every session on one account; nattermatic doesn't. Address any
|
|
7
8
|
agent as `name@host` (e.g. `reviewer@laptop.local`). An incoming message **wakes an idle Claude
|
|
8
9
|
Code or Codex session**, just like agents talking on a single machine.
|
package/install.sh
CHANGED
|
@@ -110,7 +110,10 @@ install_app() {
|
|
|
110
110
|
return 1
|
|
111
111
|
fi
|
|
112
112
|
else
|
|
113
|
-
|
|
113
|
+
# about 40 MB, so it can take a minute or two: say so, with a progress bar when there's a terminal to see it
|
|
114
|
+
echo "Downloading nattermatic.app (about 40 MB)..."
|
|
115
|
+
BAR=-s; [ -t 2 ] && BAR=-#
|
|
116
|
+
if ! curl -fSL "$BAR" "$RELEASES/nattermatic-macos-arm64.zip" -o "$TMP/app.zip" ||
|
|
114
117
|
! curl -fsSL "$RELEASES/nattermatic-macos-arm64.zip.sha256" -o "$TMP/app.sha256"; then
|
|
115
118
|
no_app "from the latest release" && link_app && return 0
|
|
116
119
|
return 1
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nattermatic",
|
|
3
|
-
"version": "0.2.
|
|
4
|
-
"description": "Let AI agents
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "Let AI agents natter to each other across different agents, accounts and machines — incoming messages wake idle Claude Code and Codex sessions",
|
|
5
5
|
"mcpName": "io.github.tombaldwin/nattermatic",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"claude",
|
package/src/claude.js
CHANGED
|
@@ -202,11 +202,16 @@ export function register(dir) {
|
|
|
202
202
|
if (wakeSupport().ok) installHook(dir)
|
|
203
203
|
}
|
|
204
204
|
|
|
205
|
-
/**
|
|
205
|
+
/**
|
|
206
|
+
* Remove nattermatic from a Claude account. Returns false if Claude Code wasn't found to remove its
|
|
207
|
+
* MCP server registration (only the hook was removed).
|
|
208
|
+
* @param {string} dir
|
|
209
|
+
*/
|
|
206
210
|
export function unregister(dir) {
|
|
207
211
|
const claude = claudeBin()
|
|
208
212
|
if (claude) for (const name of ['nattermatic', 'agentlink']) spawnSync(claude, ['mcp', 'remove', '-s', 'user', name], { stdio: 'ignore', env: envFor(dir) })
|
|
209
213
|
removeHook(dir)
|
|
214
|
+
return !!claude
|
|
210
215
|
}
|
|
211
216
|
|
|
212
217
|
// The hook that wakes idle sessions. It ends when it wakes the session, so each of these arms it
|
package/src/config.js
CHANGED
|
@@ -5,7 +5,7 @@ import crypto from 'node:crypto'
|
|
|
5
5
|
import { execFileSync } from 'node:child_process'
|
|
6
6
|
import { fingerprint } from './cert.js'
|
|
7
7
|
|
|
8
|
-
export const VERSION = '0.2.
|
|
8
|
+
export const VERSION = '0.2.1'
|
|
9
9
|
|
|
10
10
|
export function homeDir() {
|
|
11
11
|
return process.env.NATTERMATIC_HOME || path.join(os.homedir(), '.nattermatic')
|
package/src/legacy.js
CHANGED
|
@@ -49,6 +49,8 @@ export function removeOldService() {
|
|
|
49
49
|
* still use the old path. Only for the usual home, and only if `home` doesn't exist yet or holds
|
|
50
50
|
* nothing but what the installer has just put there (bin, pkg, node): entries already in `home`
|
|
51
51
|
* win, and what's left of the old home is the old program, which the new one replaces.
|
|
52
|
+
* If ~/.agentlink is itself a link (to a folder on another disk, say), the data stays where it is:
|
|
53
|
+
* the installer's entries go into that folder and `home` becomes a link to it too.
|
|
52
54
|
* Returns what happened, or null if there was nothing to move.
|
|
53
55
|
* @param {string} home @returns {string | null}
|
|
54
56
|
*/
|
|
@@ -56,13 +58,32 @@ export function moveOldHome(home) {
|
|
|
56
58
|
const old = oldHome()
|
|
57
59
|
let st
|
|
58
60
|
try { st = fs.lstatSync(old) } catch { return null }
|
|
59
|
-
|
|
61
|
+
/** @type {string | null} */
|
|
62
|
+
let target = null // where a linked ~/.agentlink really is
|
|
63
|
+
if (st.isSymbolicLink()) {
|
|
64
|
+
try { target = fs.realpathSync(old) } catch { return `${old} was left alone: it is a link to a folder that isn't there` }
|
|
65
|
+
if (!fs.statSync(target).isDirectory()) return null
|
|
66
|
+
let real = null
|
|
67
|
+
try { real = fs.realpathSync(home) } catch {}
|
|
68
|
+
if (real === target) return null // moved already: both names lead to the same folder
|
|
69
|
+
} else if (!st.isDirectory()) return null
|
|
70
|
+
if (path.resolve(home) === old) return null
|
|
60
71
|
const installed = new Set(['bin', 'pkg', 'node'])
|
|
61
72
|
/** @type {string[]} */
|
|
62
73
|
let have = []
|
|
63
74
|
try { have = fs.readdirSync(home) } catch {}
|
|
64
75
|
if (have.some(e => !installed.has(e))) return `${old} was left alone: ${home} is already in use`
|
|
65
76
|
removeOldService()
|
|
77
|
+
if (target) {
|
|
78
|
+
// the new program replaces the old one in that folder; then home leads there
|
|
79
|
+
for (const e of have) {
|
|
80
|
+
fs.rmSync(path.join(target, e), { recursive: true, force: true })
|
|
81
|
+
moveEntry(path.join(home, e), path.join(target, e))
|
|
82
|
+
}
|
|
83
|
+
fs.rmSync(home, { recursive: true, force: true })
|
|
84
|
+
fs.symlinkSync(target, home)
|
|
85
|
+
return `linked ${home} to ${target}, where ${old} leads`
|
|
86
|
+
}
|
|
66
87
|
if (!have.length) {
|
|
67
88
|
fs.rmSync(home, { recursive: true, force: true })
|
|
68
89
|
fs.renameSync(old, home)
|
|
@@ -74,6 +95,15 @@ export function moveOldHome(home) {
|
|
|
74
95
|
return `moved ${old} to ${home}`
|
|
75
96
|
}
|
|
76
97
|
|
|
98
|
+
/** rename, or copy and remove when the two are on different disks. @param {string} from @param {string} to */
|
|
99
|
+
function moveEntry(from, to) {
|
|
100
|
+
try { fs.renameSync(from, to) } catch (e) {
|
|
101
|
+
if (/** @type {NodeJS.ErrnoException} */ (e).code !== 'EXDEV') throw e
|
|
102
|
+
fs.cpSync(from, to, { recursive: true, verbatimSymlinks: true })
|
|
103
|
+
fs.rmSync(from, { recursive: true, force: true })
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
77
107
|
/**
|
|
78
108
|
* The database's old file name, renamed once nothing has it open: called by the node after it has
|
|
79
109
|
* the port, so no other node of this machine is running.
|
package/src/local.js
CHANGED
|
@@ -9,7 +9,7 @@ import path from 'node:path'
|
|
|
9
9
|
import { spawn } from 'node:child_process'
|
|
10
10
|
import crypto from 'node:crypto'
|
|
11
11
|
import { authedRequest, authedRaw, PinError, PROTO } from './tls.js'
|
|
12
|
-
import { cmpVersion, ensureHomeDir, errMsg, rosterPath, sleep, VERSION } from './config.js'
|
|
12
|
+
import { cmpVersion, ensureHomeDir, errMsg, readIdentity, rosterPath, sleep, VERSION } from './config.js'
|
|
13
13
|
import { Roster } from './roster.js'
|
|
14
14
|
import { selfCmd } from './claude.js'
|
|
15
15
|
|
|
@@ -55,6 +55,20 @@ export class Local {
|
|
|
55
55
|
this.accept = new Map([[cfg.identity.fp, crypto.createPublicKey(cfg.identity.key)]])
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
/**
|
|
59
|
+
* This machine's key as it is on disk now: `natter setup --new-network` (or joining another
|
|
60
|
+
* network) replaces it while long-lived clients, like MCP adapters, keep running, and the node
|
|
61
|
+
* they reach then proves the new key. Returns whether it changed.
|
|
62
|
+
*/
|
|
63
|
+
reloadIdentity() {
|
|
64
|
+
let id = null
|
|
65
|
+
try { id = readIdentity(this.cfg.dir) } catch {}
|
|
66
|
+
if (!id || id.fp === this.cfg.identity.fp) return false
|
|
67
|
+
this.cfg.identity = id
|
|
68
|
+
this.accept = new Map([[id.fp, crypto.createPublicKey(id.key)]])
|
|
69
|
+
return true
|
|
70
|
+
}
|
|
71
|
+
|
|
58
72
|
/** @param {{ path: string, method?: 'GET' | 'POST', body?: string, timeout?: number, signal?: AbortSignal }} o */
|
|
59
73
|
// anyProto: after an upgrade the running node may be the previous version; setup must still reach it to stop it
|
|
60
74
|
opts(o) { return { addr: this.addr, identity: this.cfg.identity, accept: this.accept, dks: readDks(this.cfg.dir), anyProto: true, ...o } }
|
|
@@ -74,9 +88,14 @@ export class Local {
|
|
|
74
88
|
*/
|
|
75
89
|
async req(method, p, obj, query = '', timeout = 15_000) {
|
|
76
90
|
let r
|
|
91
|
+
const send = () => authedRequest(this.opts({ method, path: p + query, body: obj === undefined ? '' : JSON.stringify(obj), timeout }), MAX_LOCAL_REPLY)
|
|
77
92
|
try {
|
|
78
|
-
r = await
|
|
79
|
-
} catch (e) {
|
|
93
|
+
r = await send()
|
|
94
|
+
} catch (e) {
|
|
95
|
+
// refused before anything was sent: if this machine's key was replaced meanwhile, try again with the new one
|
|
96
|
+
if (!(e instanceof PinError) || !this.reloadIdentity()) throw this.wrap(e)
|
|
97
|
+
try { r = await send() } catch (e2) { throw this.wrap(e2) }
|
|
98
|
+
}
|
|
80
99
|
/** @type {any} */
|
|
81
100
|
let data = {}
|
|
82
101
|
try { data = r.text ? JSON.parse(r.text) : {} } catch {}
|
|
@@ -90,6 +109,13 @@ export class Local {
|
|
|
90
109
|
* @returns {Promise<import('./types.js').Health | { foreign: true } | null>}
|
|
91
110
|
*/
|
|
92
111
|
async health() {
|
|
112
|
+
const h = await this.health1()
|
|
113
|
+
// the node may be this machine's under a key replaced since this process started
|
|
114
|
+
return h && 'foreign' in h && this.reloadIdentity() ? this.health1() : h
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** @returns {Promise<import('./types.js').Health | { foreign: true } | null>} */
|
|
118
|
+
async health1() {
|
|
93
119
|
try {
|
|
94
120
|
const r = await authedRequest(this.opts({ path: '/health', timeout: 1000 }))
|
|
95
121
|
return r.status === 200 ? JSON.parse(r.text) : { foreign: true }
|
package/src/setup.js
CHANGED
|
@@ -50,11 +50,19 @@ export async function setup({ host, peers = [], noService, noClaude, noCodex, no
|
|
|
50
50
|
if (join && (!pin || !token)) throw new Error('--join needs --pin and --token (use the line printed by `natter invite`)')
|
|
51
51
|
if (join && !validAddr(join)) throw new Error(`--join: "${join}" is not a host:port address`)
|
|
52
52
|
console.log(`natter setup`)
|
|
53
|
+
// a join first checks the machine at the invite's address presents the key the invite names (its
|
|
54
|
+
// invite key): before anything here is changed, so a used or expired invite leaves this machine as it was
|
|
55
|
+
/** @type {{ pem: string, fp: string } | undefined} */
|
|
56
|
+
let inviter
|
|
57
|
+
if (join) {
|
|
58
|
+
try { inviter = await peerCertificate(join) } catch (e) { throw new Error(`cannot reach ${join}: ${errMsg(e)}`) }
|
|
59
|
+
if (inviter.fp !== pin) throw new Error(`the machine at ${join} is not presenting this invite's key. Either the invite was already used, expired or was revoked (run \`natter invite\` on a member for a new one), or something else is answering at that address. Not joining.`)
|
|
60
|
+
}
|
|
53
61
|
// a machine set up as agentlink, before the rename: it keeps its key, network and messages
|
|
54
62
|
if (!homeElsewhere()) {
|
|
55
63
|
try {
|
|
56
64
|
const moved = moveOldHome(homeDir())
|
|
57
|
-
if (moved) (moved.
|
|
65
|
+
if (moved) (moved.includes('left alone') ? warn : ok)(moved)
|
|
58
66
|
if (removeOldService()) ok('removed agentlink\'s background service')
|
|
59
67
|
if (removeOldApp(appBundle())) ok('removed agentlink.app')
|
|
60
68
|
} catch (e) { throw new Error(`could not move this machine's agentlink setup to nattermatic: ${errMsg(e)}`) }
|
|
@@ -97,10 +105,7 @@ export async function setup({ host, peers = [], noService, noClaude, noCodex, no
|
|
|
97
105
|
let invite = false
|
|
98
106
|
|
|
99
107
|
if (join) {
|
|
100
|
-
// 1. the machine at the invite's address
|
|
101
|
-
let inviter
|
|
102
|
-
try { inviter = await peerCertificate(join) } catch (e) { throw new Error(`cannot reach ${join}: ${errMsg(e)}`) }
|
|
103
|
-
if (inviter.fp !== pin) throw new Error(`the machine at ${join} is not presenting this invite's key. Either the invite was already used, expired or was revoked (run \`natter invite\` on a member for a new one), or something else is answering at that address. Not joining.`)
|
|
108
|
+
// 1. checked above: the machine at the invite's address presents the key the invite names
|
|
104
109
|
// 2. keep this machine's key only if it is already a member of that same network (a key that
|
|
105
110
|
// isn't carried between networks can't be used to link them)
|
|
106
111
|
let same = member && !!netFp && roster?.net === netFp && !!identity
|
|
@@ -108,7 +113,7 @@ export async function setup({ host, peers = [], noService, noClaude, noCodex, no
|
|
|
108
113
|
// 3. join: the inviter checks the token and adds our key under our name
|
|
109
114
|
const ask = async () => {
|
|
110
115
|
try {
|
|
111
|
-
return await pinnedRequest({ addr: join, method: 'POST', path: '/join', body: JSON.stringify({ token, host: myHost, cert: certDer(id.cert) }), trust: [{ pem:
|
|
116
|
+
return await pinnedRequest({ addr: join, method: 'POST', path: '/join', body: JSON.stringify({ token, host: myHost, cert: certDer(id.cert) }), trust: [/** @type {{ pem: string, fp: string }} */ (inviter)], timeout: 20_000 })
|
|
112
117
|
} catch (e) { throw new Error(`could not join via ${join}: ${errMsg(e)}`) }
|
|
113
118
|
}
|
|
114
119
|
let r = await ask()
|
|
@@ -245,7 +250,12 @@ function restoreCert(roster) {
|
|
|
245
250
|
*/
|
|
246
251
|
function leaveNetwork() {
|
|
247
252
|
fs.rmSync(path.join(homeDir(), 'invite.key'), { force: true })
|
|
248
|
-
|
|
253
|
+
// agentlink.db: the database of a machine set up as agentlink, which the node renames when it next starts
|
|
254
|
+
for (const name of ['nattermatic.db', 'agentlink.db']) clearDb(path.join(homeDir(), name))
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** @param {string} file */
|
|
258
|
+
function clearDb(file) {
|
|
249
259
|
if (!fs.existsSync(file)) return
|
|
250
260
|
try {
|
|
251
261
|
const db = openStore(file)
|
|
@@ -430,9 +440,12 @@ export async function uninstall() {
|
|
|
430
440
|
}
|
|
431
441
|
if (homeElsewhere()) warn(`NATTERMATIC_HOME is ${homeDir()}, so the Claude accounts and shell files of ${os.homedir()} were left alone`)
|
|
432
442
|
else {
|
|
443
|
+
let unfound = false
|
|
433
444
|
for (const acct of claudeAccounts()) {
|
|
434
|
-
try { unregister(acct.dir) } catch (e) { warn(errMsg(e)) } // one account's settings mustn't stop the rest
|
|
445
|
+
try { if (!unregister(acct.dir)) unfound = true } catch (e) { warn(errMsg(e)) } // one account's settings mustn't stop the rest
|
|
435
446
|
}
|
|
447
|
+
// its sessions would go on starting nattermatic's MCP server, which can only say it's uninstalled
|
|
448
|
+
if (unfound) warn('Claude Code was not found, so nattermatic is still registered with it: run `claude mcp remove -s user nattermatic` where claude is installed')
|
|
436
449
|
removeShellBlock().forEach(warn)
|
|
437
450
|
try { removeCodex() } catch (e) { warn(`Codex: ${errMsg(e)}`) }
|
|
438
451
|
}
|