nattermatic 0.0.1 → 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/LICENSE +21 -0
- package/README.md +229 -4
- package/SECURITY.md +178 -0
- package/bin/natter.js +191 -0
- package/install.sh +232 -0
- package/package.json +62 -6
- package/src/cert.js +86 -0
- package/src/claude.js +411 -0
- package/src/cli.js +223 -0
- package/src/codex.js +247 -0
- package/src/config.js +280 -0
- package/src/crypto.js +124 -0
- package/src/discovery.js +60 -0
- package/src/legacy-env.js +4 -0
- package/src/legacy.js +130 -0
- package/src/local.js +219 -0
- package/src/mcp.js +370 -0
- package/src/node.js +1387 -0
- package/src/roster.js +517 -0
- package/src/self.js +41 -0
- package/src/setup.js +464 -0
- package/src/store.js +39 -0
- package/src/tls.js +229 -0
- package/src/types.d.ts +150 -0
- package/src/wake.js +143 -0
package/src/discovery.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// LAN discovery by encrypted UDP broadcast beacons. Deliberately does NOT use mDNS/Bonjour, so it
|
|
2
|
+
// can never interfere with the OS's own hostname announcements.
|
|
3
|
+
import dgram from 'node:dgram'
|
|
4
|
+
import os from 'node:os'
|
|
5
|
+
import { sealBeacon, openBeacon } from './crypto.js'
|
|
6
|
+
|
|
7
|
+
const EVERY = 30_000
|
|
8
|
+
const MAX_SKEW = 5 * 60_000
|
|
9
|
+
const MAX_KEYS_TRIED = 64 // anyone can send a packet; a machine on an older key is found other ways
|
|
10
|
+
|
|
11
|
+
function broadcastAddrs() {
|
|
12
|
+
const out = new Set(['255.255.255.255'])
|
|
13
|
+
for (const list of Object.values(os.networkInterfaces())) {
|
|
14
|
+
for (const i of list || []) {
|
|
15
|
+
if (i.family !== 'IPv4' || i.internal || !i.netmask) continue
|
|
16
|
+
const ip = i.address.split('.').map(Number), mask = i.netmask.split('.').map(Number)
|
|
17
|
+
out.add(ip.map((o, k) => (o | (~mask[k] & 255)) >>> 0).join('.'))
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return [...out]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* onPeer({host, fp, addr}) is called for each beacon from another member. Beacons are encrypted
|
|
25
|
+
* under the network's discovery key, so outsiders can neither read nor forge them; but a captured
|
|
26
|
+
* beacon can be replayed and a member could lie, so callers treat addr as a hint and only route to
|
|
27
|
+
* it once a TLS handshake there presents that member's key.
|
|
28
|
+
* @param {import('./types.js').Config} cfg
|
|
29
|
+
* @param {() => string[]} keys discovery keys to accept, current (used for sending) last
|
|
30
|
+
* @param {(peer: { host: string, fp: string, addr: string }) => void} onPeer
|
|
31
|
+
* @param {(...a: any[]) => void} log
|
|
32
|
+
*/
|
|
33
|
+
export function startDiscovery(cfg, keys, onPeer, log) {
|
|
34
|
+
const port = cfg.discoveryPort || cfg.port
|
|
35
|
+
const sock = dgram.createSocket({ type: 'udp4', reuseAddr: true })
|
|
36
|
+
/** @param {boolean} reply */
|
|
37
|
+
const beacon = reply => {
|
|
38
|
+
const dk = keys().at(-1)
|
|
39
|
+
return dk ? sealBeacon(dk, { host: cfg.host, fp: cfg.identity.fp, port: cfg.port, ts: Date.now(), reply }) : null
|
|
40
|
+
}
|
|
41
|
+
const announce = () => {
|
|
42
|
+
const msg = beacon(false)
|
|
43
|
+
if (msg) for (const a of broadcastAddrs()) sock.send(msg, port, a, () => {})
|
|
44
|
+
}
|
|
45
|
+
sock.on('message', (buf, rinfo) => {
|
|
46
|
+
const b = openBeacon(keys().slice(-MAX_KEYS_TRIED), buf)
|
|
47
|
+
if (!b || typeof b.host !== 'string' || typeof b.fp !== 'string' || b.fp === cfg.identity.fp) return
|
|
48
|
+
if (!Number.isFinite(b.ts) || Math.abs(Date.now() - b.ts) > MAX_SKEW) return
|
|
49
|
+
if (!Number.isInteger(b.port) || b.port <= 0 || b.port >= 65536) return
|
|
50
|
+
if (!b.reply) { const r = beacon(true); if (r) sock.send(r, port, rinfo.address, () => {}) } // answer so discovery is instant both ways
|
|
51
|
+
onPeer({ host: b.host, fp: b.fp, addr: `${rinfo.address}:${b.port}` })
|
|
52
|
+
})
|
|
53
|
+
sock.on('error', e => log('discovery socket error:', e.message))
|
|
54
|
+
sock.bind(port, () => {
|
|
55
|
+
sock.setBroadcast(true)
|
|
56
|
+
announce()
|
|
57
|
+
})
|
|
58
|
+
const t = setInterval(announce, EVERY)
|
|
59
|
+
return { announce, stop() { clearInterval(t); try { sock.close() } catch {} } }
|
|
60
|
+
}
|
package/src/legacy.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// nattermatic was called agentlink until 0.2.0. This moves a machine set up under the old name
|
|
2
|
+
// onto the new one, keeping its key, network and messages, and keeps the old names working for
|
|
3
|
+
// whatever still uses them: the user's scripts and settings, and sessions started before the upgrade.
|
|
4
|
+
import fs from 'node:fs'
|
|
5
|
+
import os from 'node:os'
|
|
6
|
+
import path from 'node:path'
|
|
7
|
+
import { spawnSync } from 'node:child_process'
|
|
8
|
+
|
|
9
|
+
export const OLD = 'agentlink'
|
|
10
|
+
/** agentlink's home, where a machine set up before the rename keeps its key and messages. */
|
|
11
|
+
export const oldHome = () => path.join(os.homedir(), '.agentlink')
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* AGENTLINK_* settings still count, under their new names, unless the new name is set too.
|
|
15
|
+
* Run before anything reads the environment.
|
|
16
|
+
*/
|
|
17
|
+
export function legacyEnv() {
|
|
18
|
+
for (const [k, v] of Object.entries(process.env)) {
|
|
19
|
+
if (!k.startsWith('AGENTLINK_')) continue
|
|
20
|
+
const n = `NATTERMATIC_${k.slice('AGENTLINK_'.length)}`
|
|
21
|
+
if (!(n in process.env)) process.env[n] = v
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const uid = () => process.getuid?.() ?? 0
|
|
26
|
+
|
|
27
|
+
/** Stop and remove agentlink's background service, so it can't restart the old node. */
|
|
28
|
+
export function removeOldService() {
|
|
29
|
+
if (process.platform === 'darwin') {
|
|
30
|
+
const plist = path.join(os.homedir(), 'Library', 'LaunchAgents', 'com.agentlink.node.plist')
|
|
31
|
+
if (!fs.existsSync(plist)) return false
|
|
32
|
+
spawnSync('launchctl', ['bootout', `gui/${uid()}/com.agentlink.node`], { stdio: 'ignore' })
|
|
33
|
+
fs.rmSync(plist, { force: true })
|
|
34
|
+
return true
|
|
35
|
+
}
|
|
36
|
+
if (process.platform === 'linux') {
|
|
37
|
+
const unit = path.join(os.homedir(), '.config', 'systemd', 'user', 'agentlink.service')
|
|
38
|
+
if (!fs.existsSync(unit)) return false
|
|
39
|
+
spawnSync('systemctl', ['--user', 'disable', '--now', 'agentlink'], { stdio: 'ignore' })
|
|
40
|
+
fs.rmSync(unit, { force: true })
|
|
41
|
+
spawnSync('systemctl', ['--user', 'daemon-reload'], { stdio: 'ignore' })
|
|
42
|
+
return true
|
|
43
|
+
}
|
|
44
|
+
return false
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Move ~/.agentlink to `home` and leave a link in its place, for sessions and shell lines that
|
|
49
|
+
* still use the old path. Only for the usual home, and only if `home` doesn't exist yet or holds
|
|
50
|
+
* nothing but what the installer has just put there (bin, pkg, node): entries already in `home`
|
|
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.
|
|
54
|
+
* Returns what happened, or null if there was nothing to move.
|
|
55
|
+
* @param {string} home @returns {string | null}
|
|
56
|
+
*/
|
|
57
|
+
export function moveOldHome(home) {
|
|
58
|
+
const old = oldHome()
|
|
59
|
+
let st
|
|
60
|
+
try { st = fs.lstatSync(old) } catch { 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
|
|
71
|
+
const installed = new Set(['bin', 'pkg', 'node'])
|
|
72
|
+
/** @type {string[]} */
|
|
73
|
+
let have = []
|
|
74
|
+
try { have = fs.readdirSync(home) } catch {}
|
|
75
|
+
if (have.some(e => !installed.has(e))) return `${old} was left alone: ${home} is already in use`
|
|
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
|
+
}
|
|
87
|
+
if (!have.length) {
|
|
88
|
+
fs.rmSync(home, { recursive: true, force: true })
|
|
89
|
+
fs.renameSync(old, home)
|
|
90
|
+
} else {
|
|
91
|
+
for (const e of fs.readdirSync(old)) if (!have.includes(e)) fs.renameSync(path.join(old, e), path.join(home, e))
|
|
92
|
+
fs.rmSync(old, { recursive: true, force: true })
|
|
93
|
+
}
|
|
94
|
+
fs.symlinkSync(home, old)
|
|
95
|
+
return `moved ${old} to ${home}`
|
|
96
|
+
}
|
|
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
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The database's old file name, renamed once nothing has it open: called by the node after it has
|
|
109
|
+
* the port, so no other node of this machine is running.
|
|
110
|
+
* @param {string} dir
|
|
111
|
+
*/
|
|
112
|
+
export function renameOldDb(dir) {
|
|
113
|
+
const from = path.join(dir, 'agentlink.db'), to = path.join(dir, 'nattermatic.db')
|
|
114
|
+
if (!fs.existsSync(from) || fs.existsSync(to)) return
|
|
115
|
+
for (const ext of ['-wal', '-shm', '']) {
|
|
116
|
+
try { fs.renameSync(from + ext, to + ext) } catch (e) { if (/** @type {any} */ (e).code !== 'ENOENT') throw e }
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** A permission rule naming agentlink's tools, under its new name. @param {any} rule @returns {any} */
|
|
121
|
+
export const renamedRule = rule => typeof rule === 'string' ? rule.replace(/^mcp__agentlink(?=__|$|\()/, 'mcp__nattermatic') : rule
|
|
122
|
+
|
|
123
|
+
/** agentlink.app, beside the app this runs from: replaced by nattermatic.app. @param {string | null} bundle */
|
|
124
|
+
export function removeOldApp(bundle) {
|
|
125
|
+
if (!bundle) return false
|
|
126
|
+
const old = path.join(path.dirname(bundle), 'agentlink.app')
|
|
127
|
+
if (old === bundle || !fs.existsSync(path.join(old, 'Contents', 'Info.plist'))) return false
|
|
128
|
+
fs.rmSync(old, { recursive: true, force: true })
|
|
129
|
+
return true
|
|
130
|
+
}
|
package/src/local.js
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// Client for the local node's loopback API, plus auto-start of the node.
|
|
2
|
+
//
|
|
3
|
+
// The local API runs over TLS on 127.0.0.1 with the same in-band identity check as connections
|
|
4
|
+
// between machines (see tls.js): the node must prove it holds this machine's key, and so must the
|
|
5
|
+
// client. Only a process that can read this machine's private key (this user) gets in, and a
|
|
6
|
+
// process squatting on the port is refused before it is sent anything.
|
|
7
|
+
import fs from 'node:fs'
|
|
8
|
+
import path from 'node:path'
|
|
9
|
+
import { spawn } from 'node:child_process'
|
|
10
|
+
import crypto from 'node:crypto'
|
|
11
|
+
import { authedRequest, authedRaw, PinError, PROTO } from './tls.js'
|
|
12
|
+
import { cmpVersion, ensureHomeDir, errMsg, readIdentity, rosterPath, sleep, VERSION } from './config.js'
|
|
13
|
+
import { Roster } from './roster.js'
|
|
14
|
+
import { selfCmd } from './claude.js'
|
|
15
|
+
|
|
16
|
+
const MAX_LOCAL_REPLY = 64 << 20 // the most an inbox read can come to: 100 messages of 64 KB, escaped
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The running node is a newer nattermatic than this process: this process (a session's adapter
|
|
20
|
+
* started before an upgrade, say) is the one that is out of date, and must not replace the node.
|
|
21
|
+
*/
|
|
22
|
+
export class StaleError extends Error {}
|
|
23
|
+
|
|
24
|
+
/** Is the node behind `h` a different version from this process, and is it newer? @param {import('./types.js').Health} h */
|
|
25
|
+
export function versionOf(h) {
|
|
26
|
+
const proto = h.proto ?? 0 // nodes from before the protocol number count as older
|
|
27
|
+
const differs = proto !== PROTO || h.version !== VERSION
|
|
28
|
+
const newer = proto !== PROTO ? proto > PROTO : cmpVersion(h.version, VERSION) > 0
|
|
29
|
+
return { differs, newer: differs && newer }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** @type {{ stamp: string, dks: string[] } | null} */
|
|
33
|
+
let dkCache = null
|
|
34
|
+
/**
|
|
35
|
+
* The discovery keys that open this machine's node, read fresh (a removal elsewhere rotates them
|
|
36
|
+
* while long-lived clients, like MCP adapters, keep running) but through the roster's own rules, so
|
|
37
|
+
* only keys that count are used. Replaying the roster is only needed when the file has changed.
|
|
38
|
+
* @param {string} [dir] @returns {string[]}
|
|
39
|
+
*/
|
|
40
|
+
function readDks(dir) {
|
|
41
|
+
try {
|
|
42
|
+
const file = rosterPath(dir)
|
|
43
|
+
const st = fs.statSync(file)
|
|
44
|
+
const stamp = `${file} ${st.mtimeMs} ${st.size}`
|
|
45
|
+
if (dkCache?.stamp !== stamp) dkCache = { stamp, dks: Roster.load(file).gateDks() }
|
|
46
|
+
return dkCache.dks
|
|
47
|
+
} catch { return [] }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export class Local {
|
|
51
|
+
/** @param {Pick<import('./types.js').Config, 'identity' | 'port' | 'dir'>} cfg */
|
|
52
|
+
constructor(cfg) {
|
|
53
|
+
this.cfg = cfg
|
|
54
|
+
this.addr = `127.0.0.1:${cfg.port}`
|
|
55
|
+
this.accept = new Map([[cfg.identity.fp, crypto.createPublicKey(cfg.identity.key)]])
|
|
56
|
+
}
|
|
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
|
+
|
|
72
|
+
/** @param {{ path: string, method?: 'GET' | 'POST', body?: string, timeout?: number, signal?: AbortSignal }} o */
|
|
73
|
+
// anyProto: after an upgrade the running node may be the previous version; setup must still reach it to stop it
|
|
74
|
+
opts(o) { return { addr: this.addr, identity: this.cfg.identity, accept: this.accept, dks: readDks(this.cfg.dir), anyProto: true, ...o } }
|
|
75
|
+
|
|
76
|
+
/** @param {unknown} e */
|
|
77
|
+
wrap(e) {
|
|
78
|
+
if (e instanceof PinError || /** @type {any} */ (e)?.code === 'EPROTO') return new Error(`port ${this.cfg.port} is not answered by this machine's nattermatic node`)
|
|
79
|
+
return new Error(`cannot reach the nattermatic node on port ${this.cfg.port}: ${errMsg(e)}`)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Request to the local node's API; throws with the node's error message on failure.
|
|
84
|
+
* @param {'GET'|'POST'} method
|
|
85
|
+
* @param {string} p
|
|
86
|
+
* @param {object} [obj]
|
|
87
|
+
* @returns {Promise<any>}
|
|
88
|
+
*/
|
|
89
|
+
async req(method, p, obj, query = '', timeout = 15_000) {
|
|
90
|
+
let r
|
|
91
|
+
const send = () => authedRequest(this.opts({ method, path: p + query, body: obj === undefined ? '' : JSON.stringify(obj), timeout }), MAX_LOCAL_REPLY)
|
|
92
|
+
try {
|
|
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
|
+
}
|
|
99
|
+
/** @type {any} */
|
|
100
|
+
let data = {}
|
|
101
|
+
try { data = r.text ? JSON.parse(r.text) : {} } catch {}
|
|
102
|
+
if (r.status < 200 || r.status >= 300) throw new Error(data.error || `HTTP ${r.status}`)
|
|
103
|
+
return data
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Is a node running on the port? null: nothing is listening. { foreign: true }: something that
|
|
108
|
+
* isn't this machine's node (another network's node, another user's, or not nattermatic at all).
|
|
109
|
+
* @returns {Promise<import('./types.js').Health | { foreign: true } | null>}
|
|
110
|
+
*/
|
|
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() {
|
|
119
|
+
try {
|
|
120
|
+
const r = await authedRequest(this.opts({ path: '/health', timeout: 1000 }))
|
|
121
|
+
return r.status === 200 ? JSON.parse(r.text) : { foreign: true }
|
|
122
|
+
} catch (e) {
|
|
123
|
+
return e instanceof PinError || /** @type {any} */ (e)?.code === 'EPROTO' ? { foreign: true } : null
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Make sure a node is running on this machine (start one in the background if not). */
|
|
128
|
+
async ensureNode() {
|
|
129
|
+
let h = await this.health()
|
|
130
|
+
if (h && 'foreign' in h) throw new Error(`port ${this.cfg.port} is used by something that is not this machine's nattermatic node`)
|
|
131
|
+
if (h && versionOf(h).newer) {
|
|
132
|
+
// nattermatic was upgraded while this process was running: replacing the node would downgrade it
|
|
133
|
+
throw new StaleError(`nattermatic on this machine was upgraded (to ${h.version}, protocol ${h.proto}) while this program (${VERSION}, protocol ${PROTO}) was running, so it can't use the node any more`)
|
|
134
|
+
}
|
|
135
|
+
// nattermatic was upgraded under a running node: replace it with this version
|
|
136
|
+
if (h && versionOf(h).differs) {
|
|
137
|
+
try { await this.req('POST', '/local/shutdown') } catch {}
|
|
138
|
+
for (let i = 0; i < 30 && (h = await this.health()) && !('foreign' in h) && versionOf(h).differs; i++) await sleep(100)
|
|
139
|
+
}
|
|
140
|
+
if (h && !('foreign' in h) && !versionOf(h).differs) return h
|
|
141
|
+
if (fs.existsSync(path.join(this.cfg.dir, 'disabled'))) throw new Error('nattermatic is uninstalled on this machine (run `natter setup` to re-enable)')
|
|
142
|
+
ensureHomeDir(this.cfg.dir)
|
|
143
|
+
const out = fs.openSync(path.join(this.cfg.dir, 'node.log'), 'a', 0o600)
|
|
144
|
+
let exited = false, spawnErr = ''
|
|
145
|
+
try {
|
|
146
|
+
// --on-demand: if another node wins the race for the port, this one exits instead of standing by.
|
|
147
|
+
// selfCmd(): a Node path that survives upgrades (the versioned execPath may be removed by brew)
|
|
148
|
+
const [cmd, ...args] = selfCmd()
|
|
149
|
+
const child = spawn(cmd, [...args, 'node', '--on-demand'], { detached: true, stdio: ['ignore', out, out], env: process.env })
|
|
150
|
+
child.on('error', e => { exited = true; spawnErr = errMsg(e) }) // e.g. ENOENT: must not crash the caller
|
|
151
|
+
child.on('exit', () => { exited = true })
|
|
152
|
+
child.unref()
|
|
153
|
+
} finally { fs.closeSync(out) }
|
|
154
|
+
for (let i = 0; i < 60; i++) {
|
|
155
|
+
await sleep(100)
|
|
156
|
+
const h2 = await this.health()
|
|
157
|
+
if (h2 && !('foreign' in h2)) return h2
|
|
158
|
+
if (exited && i > 5) break // it died (lost the race to a node still starting, or failed): one last look below
|
|
159
|
+
}
|
|
160
|
+
const h3 = await this.health()
|
|
161
|
+
if (h3 && !('foreign' in h3)) return h3
|
|
162
|
+
throw new Error(`could not start the nattermatic node${spawnErr ? ` (${spawnErr})` : ''} (see ${path.join(this.cfg.dir, 'node.log')})`)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Wait for news for the session owned by one of `pids` (and, given `cwd`, working in that folder).
|
|
167
|
+
* null: there's no such session (yet).
|
|
168
|
+
* @param {number[]} pids @param {string} [cwd]
|
|
169
|
+
* @returns {Promise<{ event: string, name?: string, count?: number } | null>}
|
|
170
|
+
*/
|
|
171
|
+
async wakeWait(pids, cwd) {
|
|
172
|
+
const { res } = await authedRaw(this.opts({ timeout: 0, path: `/local/wake?pids=${pids.join(',')}${cwd ? `&cwd=${encodeURIComponent(cwd)}` : ''}` }))
|
|
173
|
+
if (res.statusCode !== 200) { res.destroy(); return null }
|
|
174
|
+
for await (const { event, data } of sseEvents(res)) { res.destroy(); return { event, ...data } }
|
|
175
|
+
return { event: 'closed' }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Long-lived stream of messages for session `name`; `token` lets a reconnect reclaim the same name.
|
|
180
|
+
* Resolves when the stream ends.
|
|
181
|
+
* @param {string} name
|
|
182
|
+
* @param {string} token
|
|
183
|
+
* @param {(event: string, data: any) => Promise<void> | void} onEvent
|
|
184
|
+
* @param {{ signal?: AbortSignal, poll?: boolean }} [opts] poll: messages are read with the inbox tool, not pushed
|
|
185
|
+
*/
|
|
186
|
+
async stream(name, token, onEvent, { signal, poll = false } = {}) {
|
|
187
|
+
let res
|
|
188
|
+
try {
|
|
189
|
+
({ res } = await authedRaw(this.opts({
|
|
190
|
+
timeout: 0, signal,
|
|
191
|
+
// owner and cwd: the session that started this adapter, so its hook can find it: Claude Code's
|
|
192
|
+
// process (see wake.js), or Codex's shared app-server and the session's folder (see codex.js)
|
|
193
|
+
path: `/local/stream?name=${encodeURIComponent(name)}&token=${encodeURIComponent(token)}&owner=${process.ppid}&cwd=${encodeURIComponent(process.cwd())}${poll ? '&mode=poll' : ''}`,
|
|
194
|
+
})))
|
|
195
|
+
} catch (e) { throw /** @type {any} */ (e)?.name === 'AbortError' ? e : this.wrap(e) }
|
|
196
|
+
if (res.statusCode !== 200) { res.destroy(); throw new Error(`stream HTTP ${res.statusCode}`) }
|
|
197
|
+
for await (const { event, data } of sseEvents(res)) await onEvent(event, data)
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** The events of a server-sent stream, as they arrive. @param {import('node:http').IncomingMessage} res */
|
|
202
|
+
async function* sseEvents(res) {
|
|
203
|
+
const dec = new TextDecoder()
|
|
204
|
+
let buf = ''
|
|
205
|
+
for await (const chunk of res) {
|
|
206
|
+
buf += dec.decode(chunk, { stream: true })
|
|
207
|
+
let i
|
|
208
|
+
while ((i = buf.indexOf('\n\n')) >= 0) {
|
|
209
|
+
const raw = buf.slice(0, i)
|
|
210
|
+
buf = buf.slice(i + 2)
|
|
211
|
+
let event = 'message', data = ''
|
|
212
|
+
for (const line of raw.split('\n')) {
|
|
213
|
+
if (line.startsWith('event: ')) event = line.slice(7)
|
|
214
|
+
else if (line.startsWith('data: ')) data += line.slice(6)
|
|
215
|
+
}
|
|
216
|
+
if (data) yield { event, data: JSON.parse(data) }
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|