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/src/tls.js ADDED
@@ -0,0 +1,229 @@
1
+ // Connections between nattermatic machines.
2
+ //
3
+ // Nodes present a throwaway TLS certificate (a fresh key per connection), so someone who connects
4
+ // to a node's port learns nothing they could recognise the machine by later, on another network.
5
+ // Who is who is settled inside the encrypted connection instead (authedRaw): the client proves it
6
+ // belongs to the network, the server proves it is the machine expected, and only then does the
7
+ // client reveal itself and send its request. Each proof signs the connection's TLS exporter, so it
8
+ // is worthless on any other connection, and a relay in the middle (two TLS sessions) can't pass it on.
9
+ //
10
+ // Pinned TLS (pinnedRaw) is kept for joining: an invite names the key the inviting node presents
11
+ // while invites are open, and the joining machine (and curl) accept only that key.
12
+ import https from 'node:https'
13
+ import tls from 'node:tls'
14
+ import net from 'node:net'
15
+ import crypto from 'node:crypto'
16
+ import { fingerprint } from './cert.js'
17
+ import { gateTags, proveAuth, checkAuth } from './crypto.js'
18
+
19
+ /** Split "host:port" / "[v6]:port". @param {string} addr */
20
+ function splitAddr(addr) {
21
+ const m = addr.match(/^\[([0-9a-f:.]+)\]:(\d+)$/i) || addr.match(/^([^:]+):(\d+)$/)
22
+ if (!m) throw new Error(`invalid address ${addr}`)
23
+ return { host: m[1], port: Number(m[2]) }
24
+ }
25
+
26
+ export class PinError extends Error {}
27
+ /** The other machine speaks a different version of the protocol. */
28
+ export class ProtoError extends Error {
29
+ /** @param {number} theirs */
30
+ constructor(theirs) {
31
+ super(`it runs ${theirs > PROTO ? 'a newer' : 'an older'} version of nattermatic (protocol ${theirs}; this machine: ${PROTO}). Upgrade the older machine: re-run the installer, or \`npm install -g nattermatic\` and \`natter setup\``)
32
+ }
33
+ }
34
+
35
+ /**
36
+ * The version of the machine-to-machine protocol (the identity exchange, roster and message
37
+ * formats). Machines exchange it first, so a mismatch is reported as such instead of looking like
38
+ * a stranger. Bump it on any incompatible change.
39
+ */
40
+ export const PROTO = 1
41
+ /** The largest request or response between machines. A roster always fits (see roster.js). */
42
+ export const MAX_BODY = 8 << 20
43
+ /** The slowest link we wait for, in bytes per ms (about 200 kbit/s). */
44
+ export const MIN_RATE = 25
45
+ const notExpected = () => new PinError('not the expected machine (key mismatch)')
46
+
47
+ /** A connection's TLS exporter: the value both identity proofs sign. @param {tls.TLSSocket} sock */
48
+ export const exporterOf = sock => sock.exportKeyingMaterial(32, 'EXPORTER-agentlink-auth', Buffer.alloc(0))
49
+
50
+ /** @param {unknown} e */
51
+ function mapErr(e) {
52
+ const code = /** @type {any} */ (e)?.code
53
+ // the far end doesn't speak TLS (a plain HTTP server answers our handshake with garbage)
54
+ if (code === 'EPROTO' || /^ERR_SSL_/.test(code || '')) return Object.assign(new Error('not a nattermatic node (no TLS)'), { code: 'EPROTO' })
55
+ return e
56
+ }
57
+
58
+ /** @param {import('node:http').IncomingMessage} res @param {number} max */
59
+ async function readAll(res, max) {
60
+ if (Number(res.headers['content-length']) > max) { res.destroy(); throw new Error('response too large') }
61
+ /** @type {Buffer[]} */
62
+ const chunks = []
63
+ let n = 0
64
+ for await (const c of res) {
65
+ n += c.length
66
+ if (n > max) { res.destroy(); throw new Error('response too large') }
67
+ chunks.push(c)
68
+ }
69
+ return Buffer.concat(chunks).toString('utf8')
70
+ }
71
+
72
+ /**
73
+ * @typedef {{
74
+ * addr: string, method?: 'GET' | 'POST', path: string, body?: string, headers?: Record<string, string>,
75
+ * identity: import('./types.js').Identity,
76
+ * accept: Map<string, crypto.KeyObject>, dks: string[], timeout?: number, signal?: AbortSignal,
77
+ * anyProto?: boolean, deadline?: number,
78
+ * }} AuthOpts
79
+ * accept: the machines we are willing to be talking to (fingerprint -> public key)
80
+ * dks: the network's discovery keys (oldest first), which open the gate
81
+ * anyProto: talk to a node with a different protocol version (the local node after an upgrade, to
82
+ * stop it); otherwise that fails with ProtoError
83
+ * deadline: ms the whole exchange may take (authedRequest), when the caller can't wait for the usual limit
84
+ */
85
+
86
+ /**
87
+ * A request to the machine at `addr`, which must prove it holds one of `accept`'s keys before we
88
+ * reveal who we are or send a byte of the request. Resolves once the response headers arrive.
89
+ * @param {AuthOpts} o
90
+ * @returns {Promise<{ res: import('node:http').IncomingMessage, peerFp: string }>}
91
+ */
92
+ export async function authedRaw(o) {
93
+ const { host, port } = splitAddr(o.addr)
94
+ // one connection for both steps; no SNI: it would show the machine's name to anyone watching
95
+ const agent = new https.Agent({ keepAlive: true, maxSockets: 1, rejectUnauthorized: false, minVersion: 'TLSv1.3' })
96
+ const base = { host, port, agent, servername: '' }
97
+ // The first step answers to anyone at the address, who has proved nothing yet. The timeout below only
98
+ // notices silence, so someone sending a byte now and then would otherwise hold this open for good.
99
+ const first = new AbortController()
100
+ const limit = setTimeout(() => first.abort(), 2 * (o.timeout || 4000))
101
+ try {
102
+ // 1. the gate: show we belong to the network; the server answers with its signed identity
103
+ /** @type {tls.TLSSocket | null} */
104
+ let sock = null
105
+ const r1 = await new Promise((resolve, reject) => {
106
+ // the timeout option, not req.setTimeout: that one only starts once the connection is up, so a
107
+ // silently dropped connect would wait out the OS's limit (over a minute)
108
+ const signal = o.signal ? AbortSignal.any([o.signal, first.signal]) : first.signal
109
+ const req = https.request({ ...base, signal, method: 'GET', path: '/v1/auth', headers: { 'x-agentlink-proto': String(PROTO) }, timeout: o.timeout || 4000 }, resolve)
110
+ req.on('timeout', () => req.destroy(new Error('timed out')))
111
+ req.on('error', e => reject(mapErr(e)))
112
+ req.on('socket', s => {
113
+ const t = /** @type {tls.TLSSocket} */ (s)
114
+ t.once('secureConnect', () => {
115
+ sock = t
116
+ try { req.setHeader('x-agentlink-gate', gateTags(o.dks, exporterOf(t))); req.end() } catch (e) { req.destroy(/** @type {Error} */ (e)) }
117
+ })
118
+ })
119
+ })
120
+ const text = await readAll(r1, 4096)
121
+ if (!sock) throw notExpected()
122
+ const ekm = exporterOf(sock)
123
+ /** @type {any} */
124
+ let who = null
125
+ try { who = JSON.parse(text) } catch {}
126
+ // a member of our network running another version (the claim is only used for the message)
127
+ if (r1.statusCode === 200 && Number.isSafeInteger(who?.proto) && who.proto !== PROTO && !o.anyProto) throw new ProtoError(who.proto)
128
+ const key = r1.statusCode === 200 && typeof who?.fp === 'string' ? o.accept.get(who.fp) : undefined
129
+ if (!key || !checkAuth(key, 'server', ekm, who.sig)) throw notExpected()
130
+ const peerFp = String(who.fp)
131
+ clearTimeout(limit)
132
+
133
+ // 2. the request, carrying our own signed identity, on the same connection only
134
+ const body = o.body ?? ''
135
+ const proof = `${o.identity.fp}.${proveAuth(o.identity.key, 'client', ekm)}`
136
+ const res = await new Promise((resolve, reject) => {
137
+ const req = https.request({
138
+ ...base, signal: o.signal, method: o.method || 'GET', path: o.path,
139
+ headers: { ...(body ? { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body) } : {}), ...o.headers, 'x-agentlink-auth': proof },
140
+ }, resolve)
141
+ req.setTimeout(o.timeout ?? 4000, () => req.destroy(new Error('timed out')))
142
+ req.on('error', e => reject(mapErr(e)))
143
+ // nothing is written until the request has the connection the server proved itself on
144
+ req.on('socket', s => { if (s === sock) req.end(body); else req.destroy(new PinError('the connection was replaced before the request was sent')) })
145
+ })
146
+ res.once('close', () => agent.destroy())
147
+ return { res, peerFp }
148
+ } catch (e) {
149
+ clearTimeout(limit)
150
+ agent.destroy()
151
+ throw first.signal.aborted ? new Error('timed out') : e
152
+ }
153
+ }
154
+
155
+ /**
156
+ * authedRaw, reading the (bounded) response body.
157
+ * @param {AuthOpts} o @param {number} [max] largest response body accepted
158
+ * @returns {Promise<{ status: number, text: string, peerFp: string }>}
159
+ */
160
+ export async function authedRequest(o, max = MAX_BODY) {
161
+ // the whole exchange has an end, however slowly the other side trickles it out
162
+ const whole = new AbortController()
163
+ const limit = setTimeout(() => whole.abort(), o.deadline ?? 3 * (o.timeout || 4000) + max / MIN_RATE)
164
+ try {
165
+ const { res, peerFp } = await authedRaw({ ...o, signal: o.signal ? AbortSignal.any([o.signal, whole.signal]) : whole.signal })
166
+ const text = await readAll(res, max)
167
+ if (whole.signal.aborted) throw new Error('timed out') // a body with no length just stops: that isn't its end
168
+ return { status: res.statusCode || 0, text, peerFp }
169
+ } catch (e) {
170
+ throw whole.signal.aborted ? new Error('timed out') : e
171
+ } finally { clearTimeout(limit) }
172
+ }
173
+
174
+ /**
175
+ * A request whose server must present one of `trust`, checked inside the handshake: on a mismatch
176
+ * nothing is sent. Used to join through an invite (which names the key to expect).
177
+ * @param {{ addr: string, method?: 'GET' | 'POST', path: string, body?: string, trust: { pem: string, fp: string }[], timeout?: number }} o
178
+ * @param {number} [max]
179
+ * @returns {Promise<{ status: number, text: string, peerFp: string }>}
180
+ */
181
+ export async function pinnedRequest(o, max = MAX_BODY) {
182
+ const { host, port } = splitAddr(o.addr)
183
+ const allowed = new Set(o.trust.map(t => t.fp))
184
+ const body = o.body ?? ''
185
+ let pinFailed = false
186
+ const res = await new Promise((resolve, reject) => {
187
+ const req = https.request({
188
+ host, port, method: o.method || 'GET', path: o.path, agent: false,
189
+ headers: body ? { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body) } : {},
190
+ timeout: o.timeout ?? 4000, // covers connecting too (see authedRaw)
191
+ ca: o.trust.map(t => t.pem), // only these certificates can validate
192
+ minVersion: 'TLSv1.2',
193
+ rejectUnauthorized: true, // said outright: left out, NODE_TLS_REJECT_UNAUTHORIZED=0 turns the pin off
194
+ checkServerIdentity: (_h, c) => {
195
+ if (allowed.has(fingerprint(new crypto.X509Certificate(c.raw)))) return undefined
196
+ pinFailed = true
197
+ return notExpected()
198
+ },
199
+ }, resolve)
200
+ req.on('timeout', () => req.destroy(new Error('timed out')))
201
+ req.on('error', e => {
202
+ const code = /** @type {any} */ (e).code
203
+ // a server whose certificate isn't one of ours fails CA validation before the identity check
204
+ if (pinFailed || e instanceof PinError || /SELF_SIGNED|UNABLE_TO_VERIFY|CERT_/.test(code || '')) reject(notExpected())
205
+ else reject(mapErr(e))
206
+ })
207
+ req.end(body)
208
+ })
209
+ return { status: res.statusCode || 0, text: await readAll(res, max), peerFp: '' }
210
+ }
211
+
212
+ /**
213
+ * The certificate the server at `addr` presents, without sending it anything (used to check an
214
+ * invite's pinned fingerprint before talking to the inviting machine).
215
+ * @param {string} addr @returns {Promise<{ pem: string, fp: string }>}
216
+ */
217
+ export function peerCertificate(addr, timeout = 5000) {
218
+ const { host, port } = splitAddr(addr)
219
+ return new Promise((resolve, reject) => {
220
+ const sock = tls.connect({ host, port, rejectUnauthorized: false, minVersion: 'TLSv1.2', ...(net.isIP(host) ? {} : { servername: host }) }, () => {
221
+ const x = sock.getPeerX509Certificate()
222
+ sock.destroy()
223
+ if (!x) return reject(new Error(`${addr} presented no certificate`))
224
+ resolve({ pem: x.toString(), fp: fingerprint(x) })
225
+ })
226
+ sock.setTimeout(timeout, () => sock.destroy(new Error(`timed out connecting to ${addr}`)))
227
+ sock.on('error', reject)
228
+ })
229
+ }
package/src/types.d.ts ADDED
@@ -0,0 +1,150 @@
1
+ // Shared shapes, used from JSDoc in the plain-JS sources (type-checked by `npm run typecheck`).
2
+
3
+ /** A configured or command-line peer: a name, plus an explicit address if one was given. */
4
+ export interface PeerSpec { host: string; addr: string | null }
5
+
6
+ /** The loaded configuration (config.json merged with NATTERMATIC_* environment overrides). */
7
+ export interface Config {
8
+ dir: string
9
+ /** This machine's key and certificate (PEM) and its fingerprint. */
10
+ identity: Identity
11
+ /** The network: the founding machine's fingerprint. */
12
+ net: string
13
+ /** This machine's name, e.g. "laptop.local". */
14
+ host: string
15
+ /** Other names that reach this machine (IPs, OS hostname, localhost). */
16
+ aliases: string[]
17
+ port: number
18
+ peers: PeerSpec[]
19
+ /** host -> "ip:port" overrides. */
20
+ routes: Record<string, string>
21
+ discoveryPort?: number
22
+ discovery: boolean
23
+ ttlMs: number
24
+ maxWaiting: number
25
+ maxStoreBytes: number
26
+ autoAliases: boolean
27
+ }
28
+
29
+ /** A signed message as it travels between nodes. */
30
+ export interface Envelope {
31
+ id: string
32
+ /** "name@host" */
33
+ from: string
34
+ /** "name@host" */
35
+ to: string
36
+ body: string
37
+ sent_at: number
38
+ expires_at: number
39
+ sig: string
40
+ }
41
+
42
+ export type MessageRole = 'outbox' | 'held' | 'inbox'
43
+ export type MessageState = 'pending' | 'forwarded' | 'queued' | 'pushed' | 'delivered' | 'expired' | 'failed'
44
+
45
+ /** A row of the messages table (see store.js). */
46
+ export interface MessageRow {
47
+ id: string
48
+ from_addr: string
49
+ to_addr: string
50
+ to_host: string
51
+ to_name: string
52
+ body: string
53
+ sent_at: number
54
+ expires_at: number
55
+ sig: string
56
+ role: MessageRole
57
+ state: MessageState
58
+ attempts: number
59
+ next_attempt: number
60
+ replicated: number
61
+ last_error: string | null
62
+ updated_at: number
63
+ }
64
+
65
+ /** A row of the peers table. */
66
+ export interface PeerRow {
67
+ host: string
68
+ addr: string | null
69
+ last_seen: number
70
+ configured: number
71
+ /** Set when `host` is another name (IP, Tailscale name) for the node called alias_of. */
72
+ alias_of: string | null
73
+ /** Unused (kept for older databases). */
74
+ verified: number
75
+ }
76
+
77
+ /** A machine's key pair (PEM) and fingerprint (sha256 of the public key, base64). */
78
+ export interface Identity { key: string; cert: string; fp: string }
79
+
80
+ /** A signed membership record (see roster.js). */
81
+ export interface RosterRecord {
82
+ type: 'add' | 'remove' | 'snapshot'
83
+ /** add: the machine's name */
84
+ host?: string
85
+ /** the machine added or removed */
86
+ fp: string
87
+ /** add: the machine's certificate, base64 DER */
88
+ cert?: string
89
+ at: number
90
+ /** fingerprint of the member that signed this record */
91
+ by: string
92
+ /** genesis add / remove: the (next) discovery key */
93
+ dk?: string
94
+ /** remove: ids (recId) of the records signed by the removed machine that the remover had; any
95
+ * other record it signed is void, whatever its timestamp */
96
+ seen?: string[]
97
+ /** snapshot: the members, in joining order: [host, fp, cert, at, by] */
98
+ list?: [string, string, string, number, string][]
99
+ /** snapshot: the discovery keys that still open gates, oldest first: [key, when it came in] */
100
+ keys?: [string, number][]
101
+ sig: string
102
+ }
103
+
104
+ /** A current member, as computed from the roster. */
105
+ export interface Member {
106
+ host: string
107
+ fp: string
108
+ pem: string
109
+ publicKey: import('node:crypto').KeyObject
110
+ at: number
111
+ by: string
112
+ /** seniority: 0 for the founding machine, then in the order machines joined. A machine can
113
+ * remove only machines that joined after it. */
114
+ rank: number
115
+ }
116
+
117
+ /** What accept() did with an incoming envelope; null means rejected as invalid. */
118
+ export type AcceptResult = 'live' | 'queued' | 'held' | 'expired' | 'full' | 'unknown-sender' | null
119
+
120
+ /** Health reply from a node's /health endpoint. */
121
+ export interface Health { ok: boolean; host: string; net: string; fp: string; version: string; proto?: number; pid: number }
122
+
123
+ /** A Claude account on this machine ('' dir = the default account). */
124
+ export interface ClaudeAccount { dir: string; alias: string; aliasValue?: string }
125
+
126
+ /** config.json as written by setup / edited by hand (all optional). */
127
+ export interface ConfigFile {
128
+ host?: string
129
+ aliases?: string[]
130
+ autoAliases?: boolean
131
+ port?: number
132
+ peers?: string[]
133
+ routes?: Record<string, string>
134
+ discoveryPort?: number
135
+ discovery?: boolean
136
+ ttlDays?: number
137
+ maxWaiting?: number
138
+ maxStoreMB?: number
139
+ }
140
+
141
+ /** A message pushed to a session over the local stream. */
142
+ export interface InboundMessage { id: string; from: string; to: string; body: string; sent_at: number }
143
+
144
+ /** Reply of the node's GET /local/peers. */
145
+ export interface PeersReply {
146
+ self: string
147
+ local: string[]
148
+ /** error: why an offline machine couldn't be reached */
149
+ remote: { host: string; online: boolean; sessions: string[]; error?: string }[]
150
+ }
package/src/wake.js ADDED
@@ -0,0 +1,143 @@
1
+ // `natter wake`: run by a Claude Code hook (see installHook in claude.js). It waits in the background
2
+ // until the session it belongs to has a new message, then exits 2, which wakes the session even when
3
+ // it's idle. What it tells Claude is fixed text: the messages themselves are read with the inbox
4
+ // tool, so nothing another agent wrote ever arrives as a hook's output.
5
+ import { execFileSync } from 'node:child_process'
6
+ import fs from 'node:fs'
7
+ import path from 'node:path'
8
+ import { homeDir, loadConfig } from './config.js'
9
+ import { Local } from './local.js'
10
+ import { isPrintFlag } from './claude.js'
11
+
12
+ const RETRY_MS = 5000
13
+ const SLOW_RETRY_MS = 30_000
14
+ const SLOW_AFTER_MS = 120_000
15
+ // The node is up but has no session of ours. That's normal for a while: a session that was running
16
+ // when nattermatic was installed has no adapter until someone types /reload-plugins, and that fires no
17
+ // hook, so this wait has to still be here when it happens.
18
+ const GIVE_UP_MS = 86400_000
19
+
20
+ /** This process's ancestors, nearest first: one of them is the Claude session that started us. */
21
+ function ancestors() {
22
+ const out = []
23
+ let pid = process.ppid
24
+ for (let i = 0; i < 8 && pid > 1; i++) {
25
+ out.push(pid)
26
+ try { pid = Number(execFileSync('ps', ['-o', 'ppid=', '-p', String(pid)], { encoding: 'utf8', timeout: 2000 }).trim()) } catch { break }
27
+ }
28
+ return out
29
+ }
30
+
31
+ /** The Claude Code process among `pids`, nearest first, with its terminal and command line. @param {number[]} pids */
32
+ function findClaude(pids) {
33
+ for (const pid of pids) {
34
+ const row = execFileSync('ps', ['-ww', '-o', 'tty=,args=', '-p', String(pid)], { encoding: 'utf8', timeout: 2000 }).trim()
35
+ const [tty = '', ...args] = row.split(/\s+/)
36
+ // by what's running, not by anything that merely mentions claude (the shell running this hook might)
37
+ if (/^claude(\.exe)?$/.test(path.basename(args[0] || '')) || /[\\/]claude-code[\\/]/.test(args[1] || '')) return { pid, tty, args: args.join(' ') }
38
+ }
39
+ return null
40
+ }
41
+
42
+ /**
43
+ * The options at the front of a claude command line as ps prints it. ps loses the quoting, so whatever
44
+ * follows the first plain word may be a prompt: "mkdir -p src" has a -p in it, and that's not an option.
45
+ * Nor is a word that looks like a channel unless a channel option comes before it.
46
+ * @param {string} cmdline
47
+ */
48
+ export function launchFlags(cmdline) {
49
+ const words = cmdline.trim().split(/\s+/).slice(1)
50
+ if (/[\\/]claude-code[\\/]/.test(words[0] || '')) words.shift() // node .../claude-code/cli.js
51
+ const out = []
52
+ const plain = (/** @type {number} */ i) => i < words.length && !words[i].startsWith('-')
53
+ for (let i = 0; i < words.length && words[i].startsWith('-'); i++) {
54
+ const w = words[i]
55
+ out.push(w)
56
+ if (CHANNELS.test(w)) while (plain(i + 1)) out.push(words[++i])
57
+ else if (TAKES_VALUES.test(w)) while (plain(i + 1)) i++
58
+ else if (TAKES_VALUE.test(w) && plain(i + 1)) i++
59
+ }
60
+ return out
61
+ }
62
+ // Claude Code's options that take a value (`claude --help`), which would otherwise look like the
63
+ // start of a prompt, and hide what follows
64
+ const TAKES_VALUE = /^(--agent|--agents|--append-system-prompt|--autocompact|--cloud|-d|--debug|--debug-file|--effort|--environment|--fallback-model|--from-pr|--input-format|--json-schema|--max-budget-usd|--model|-n|--name|--output-format|--permission-mode|--permission-prompts|--plugin-dir|--plugin-url|--prompt-suggestions|--remote-control|--remote-control-session-name-prefix|-r|--resume|--session-id|--setting-sources|--settings|--system-prompt|--system-prompt-snapshot|--teleport|-w|--worktree)$/
65
+ const TAKES_VALUES = /^(--add-dir|--allowedTools|--allowed-tools|--betas|--disallowedTools|--disallowed-tools|--file|--mcp-config|--tools)$/
66
+ const CHANNELS = /^--(channels|dangerously-load-development-channels)$/
67
+
68
+ /**
69
+ * Is this an interactive session? Only those can be woken, and a script must never wait on this
70
+ * hook: a `claude -p` run doesn't finish until its hooks do. Claude Code says which it is in
71
+ * CLAUDE_CODE_ENTRYPOINT (sdk-cli for -p, however the options are ordered); the flags are a second
72
+ * check. It also tells its hooks whether someone is attending the session, which covers one with no
73
+ * terminal (an IDE's); a Claude Code that doesn't say is judged by its terminal.
74
+ * @param {string} args an ancestor's command line @param {string} tty its terminal, as ps prints it
75
+ * @param {NodeJS.ProcessEnv} [env]
76
+ */
77
+ export function interactive(args, tty, env = process.env) {
78
+ if (/^sdk/.test(env.CLAUDE_CODE_ENTRYPOINT || '')) return false
79
+ const flags = launchFlags(args)
80
+ if (flags.some(isPrintFlag)) return false
81
+ if (env.CLAUDE_CODE_SESSION_ATTENDED) return env.CLAUDE_CODE_SESSION_ATTENDED === '1'
82
+ if (flags.some(f => /^--(input|output)-format(=|$)/.test(f))) return false
83
+ return !!tty && !/^[?-]+$/.test(tty.trim())
84
+ }
85
+
86
+ /** @param {number} n @param {string} name */
87
+ export const wakeText = (n, name) => `This session is on the nattermatic agent network as ${name}, and ${n === 1 ? 'a new message has' : `${n} new messages have`} arrived from another agent. Read ${n === 1 ? 'it' : 'them'} now with the mcp__nattermatic__inbox tool (load it with ToolSearch if it isn't listed), then act on ${n === 1 ? 'it' : 'them'} as requests from a colleague.`
88
+
89
+ export async function runWake() {
90
+ /** @type {Local} */
91
+ let local
92
+ try {
93
+ if (fs.existsSync(path.join(homeDir(), 'disabled'))) return 0
94
+ local = new Local(loadConfig())
95
+ } catch { return 0 } // not set up: stay out of the session's way
96
+ const testing = !!process.env.NATTERMATIC_WAKE_ANY // (the end-to-end tests have no Claude and no terminal)
97
+ // Claude Code tells its hooks who it is: going by ps is for one that doesn't (and can't see a
98
+ // renamed claude, or work where ps has no -p)
99
+ const told = testing ? 0 : Number(process.env.CLAUDE_PID) || 0
100
+ const pids = told ? [told] : ancestors()
101
+ /** @type {{ pid: number, tty: string, args: string } | null} */
102
+ let claude = null
103
+ try { claude = findClaude(pids) } catch {}
104
+ if (told) claude ||= { pid: told, tty: '', args: '' }
105
+ if (!testing && !(claude && interactive(claude.args, claude.tty))) return 0
106
+ // the Claude that started us, and only it: an enclosing session must not be mistaken for ours
107
+ const mine = claude && !testing ? [claude.pid] : pids
108
+ const owner = mine[0]
109
+ // one wait per session: each turn starts another, and the newest takes over, even with no node to ask
110
+ const mark = path.join(homeDir(), `wake-${owner}`)
111
+ const me = String(process.pid)
112
+ sweepMarks()
113
+ try { fs.writeFileSync(mark, me) } catch {}
114
+ const isMine = () => { try { return fs.readFileSync(mark, 'utf8') === me } catch { return true } }
115
+ const finish = (/** @type {number} */ code) => { if (isMine()) fs.rmSync(mark, { force: true }); return code }
116
+ let since = Date.now()
117
+ for (;;) {
118
+ if (!isMine()) return 0 // before asking: the newest wait is the one to be told
119
+ /** @type {{ event: string, name?: string, count?: number } | null} */
120
+ let r = null, reached = true
121
+ try { r = await local.wakeWait(mine) } catch { reached = false }
122
+ if (r?.event === 'wake') { process.stderr.write(`${wakeText(r.count || 1, r.name || 'this session')}\n`); return finish(2) }
123
+ if (r?.event === 'done') return finish(0) // a newer wait took over, or this session is woken another way
124
+ if (!isMine()) return 0
125
+ try { process.kill(owner, 0) } catch { return finish(0) } // Claude has gone
126
+ // no node to ask (it may be restarting), or the session was there a moment ago: keep going
127
+ if (!reached || r?.event === 'closed') since = Date.now()
128
+ const waited = Date.now() - since
129
+ if (waited > GIVE_UP_MS) return finish(0)
130
+ await new Promise(res => setTimeout(res, waited > SLOW_AFTER_MS ? SLOW_RETRY_MS : RETRY_MS))
131
+ }
132
+ }
133
+
134
+ /** A session that ended while waiting leaves its mark behind: clear those of sessions that are gone. */
135
+ function sweepMarks() {
136
+ try {
137
+ for (const f of fs.readdirSync(homeDir())) {
138
+ const pid = Number(f.match(/^wake-(\d+)$/)?.[1])
139
+ if (!pid) continue
140
+ try { process.kill(pid, 0) } catch (e) { if (/** @type {any} */ (e).code === 'ESRCH') fs.rmSync(path.join(homeDir(), f), { force: true }) }
141
+ }
142
+ } catch {}
143
+ }