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 CHANGED
@@ -2,7 +2,8 @@
2
2
 
3
3
  # nattermatic
4
4
 
5
- Let AI agents on different Claude accounts — and different machines — message each other.
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
- if ! curl -fsSL "$RELEASES/nattermatic-macos-arm64.zip" -o "$TMP/app.zip" ||
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.0",
4
- "description": "Let AI agents on different Claude accounts and machines natter to each other — incoming messages wake idle Claude Code and Codex sessions",
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
- /** @param {string} dir */
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.0'
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
- if (!st.isDirectory() || path.resolve(home) === old) return null
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 authedRequest(this.opts({ method, path: p + query, body: obj === undefined ? '' : JSON.stringify(obj), timeout }), MAX_LOCAL_REPLY)
79
- } catch (e) { throw this.wrap(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.startsWith('moved') ? ok : warn)(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 must present the key the invite names (its invite key)
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: inviter.pem, fp: pin }], timeout: 20_000 })
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
- const file = path.join(homeDir(), 'nattermatic.db')
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
  }