thinkpool-pair 0.7.14 → 0.7.15
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/account.mjs +67 -3
- package/bridge.mjs +18 -2
- package/package.json +1 -1
- package/service.mjs +35 -15
package/account.mjs
CHANGED
|
@@ -100,9 +100,13 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
100
100
|
}
|
|
101
101
|
|
|
102
102
|
const DEFAULT_DIR = process.cwd() // unbound sessions auto-serve here; `bind` overrides per session
|
|
103
|
-
process.
|
|
103
|
+
const svcHint = process.env.THINKPOOL_PAIR_AUTOUPDATE === '1'
|
|
104
|
+
? ' (auto-updating background service — survives reboot, tracks @latest)\n'
|
|
105
|
+
: ' (run hands-off + auto-updating: npx thinkpool-pair install-service)\n'
|
|
106
|
+
process.stderr.write(`\n ◆ thinkpool-pair — account mode · ${email}\n Auto-serving your sessions from ${DEFAULT_DIR}\n (point one at another repo: npx thinkpool-pair bind <code> <dir>)\n${svcHint}`)
|
|
104
107
|
|
|
105
108
|
const children = new Map() // room -> child process
|
|
109
|
+
const childIdle = new Map() // room -> bool (last idle report; unknown = not idle)
|
|
106
110
|
const warned = new Set()
|
|
107
111
|
|
|
108
112
|
// Account presence — so the web dashboard can show "● Bridge active" for this
|
|
@@ -132,9 +136,16 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
132
136
|
}
|
|
133
137
|
warned.delete(room)
|
|
134
138
|
process.stderr.write(`\n ◆ serving ${room}${r.name ? ` "${r.name}"` : ''} → ${dir}${dirs[room] ? '' : ' (default)'}\n`)
|
|
135
|
-
|
|
139
|
+
// Children NEVER self-update (only the supervisor restart re-resolves
|
|
140
|
+
// @latest); they report idle to us over IPC so we can restart at a moment
|
|
141
|
+
// when every session is quiet. Strip AUTOUPDATE so an inherited service env
|
|
142
|
+
// can't make a child self-exit; flag it as an account child instead.
|
|
143
|
+
const env = { ...process.env, THINKPOOL_PAIR_ACCOUNT_CHILD: '1' }
|
|
144
|
+
delete env.THINKPOOL_PAIR_AUTOUPDATE
|
|
145
|
+
const child = spawn(process.execPath, [BRIDGE, room, '--headless', '--auto=claude'], { cwd: dir, stdio: ['inherit', 'inherit', 'inherit', 'ipc'], env })
|
|
136
146
|
children.set(room, child)
|
|
137
|
-
child.on('
|
|
147
|
+
child.on('message', (m) => { if (m && m.t === 'idle') childIdle.set(room, !!m.idle) })
|
|
148
|
+
child.on('exit', () => { children.delete(room); childIdle.delete(room) }) // re-served on the next tick
|
|
138
149
|
}
|
|
139
150
|
pushPresence()
|
|
140
151
|
}
|
|
@@ -152,5 +163,58 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
152
163
|
setTimeout(() => process.exit(0), 1500) // hard backstop if the flush hangs
|
|
153
164
|
}
|
|
154
165
|
for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.on(sig, () => stop(sig))
|
|
166
|
+
|
|
167
|
+
// ── auto-update (account-service tier only) ───────────────────────────────
|
|
168
|
+
// launchd (KeepAlive) / systemd (Restart=always) re-run `npx -y
|
|
169
|
+
// thinkpool-pair@latest` (no room) on every restart, so a clean exit here
|
|
170
|
+
// pulls the newest publish — and because the new supervisor re-spawns children
|
|
171
|
+
// from the newly-resolved bridge, the room children get the new version too
|
|
172
|
+
// (they can't self-update; only this restart propagates it). Gated to
|
|
173
|
+
// THINKPOOL_PAIR_AUTOUPDATE=1 (set by the account service installer). We wait
|
|
174
|
+
// for EVERY child to report idle so no one's mid-keystroke when we cycle; each
|
|
175
|
+
// child persists + resumes its session, so the restart is invisible in-room.
|
|
176
|
+
// THINKPOOL_PAIR_UPDATE_INTERVAL registry poll seconds (default 1800)
|
|
177
|
+
// THINKPOOL_PAIR_UPDATE_IDLE child idle seconds before a restart (default 90)
|
|
178
|
+
if (process.env.THINKPOOL_PAIR_AUTOUPDATE === '1' && VERSION) {
|
|
179
|
+
const POLL_MS = Math.max(60, parseInt(process.env.THINKPOOL_PAIR_UPDATE_INTERVAL, 10) || 1800) * 1000
|
|
180
|
+
let pendingUpdate = null
|
|
181
|
+
// Strictly-newer x.y.z core; ignore prerelease tails (never downgrade/churn).
|
|
182
|
+
const isNewer = (latest, cur) => {
|
|
183
|
+
const core = (v) => String(v).split('-')[0].split('.').map((n) => parseInt(n, 10) || 0)
|
|
184
|
+
const a = core(latest), b = core(cur)
|
|
185
|
+
for (let i = 0; i < 3; i++) { if ((a[i] || 0) > (b[i] || 0)) return true; if ((a[i] || 0) < (b[i] || 0)) return false }
|
|
186
|
+
return false
|
|
187
|
+
}
|
|
188
|
+
const fetchLatest = async () => {
|
|
189
|
+
const ctrl = new AbortController()
|
|
190
|
+
const to = setTimeout(() => ctrl.abort(), 8000)
|
|
191
|
+
try {
|
|
192
|
+
const r = await fetch('https://registry.npmjs.org/thinkpool-pair/latest', { signal: ctrl.signal, headers: { accept: 'application/json' } })
|
|
193
|
+
if (!r.ok) return null
|
|
194
|
+
const j = await r.json()
|
|
195
|
+
return typeof j?.version === 'string' ? j.version : null
|
|
196
|
+
} catch { return null } finally { clearTimeout(to) }
|
|
197
|
+
}
|
|
198
|
+
// Idle iff every served child has reported idle. No children → trivially idle.
|
|
199
|
+
// Unknown (no report yet) counts as NOT idle, so we never cycle a fresh child.
|
|
200
|
+
const allIdle = () => { for (const room of children.keys()) if (!childIdle.get(room)) return false; return true }
|
|
201
|
+
const applyIfIdle = () => {
|
|
202
|
+
if (!pendingUpdate || stopping || !allIdle()) return
|
|
203
|
+
process.stderr.write(`\n ◆ thinkpool-pair ${pendingUpdate} ready (running ${VERSION}) — restarting account bridge to update; sessions resume.\n`)
|
|
204
|
+
stop('SIGTERM') // graceful: children persist + leave presence; exit 0 → service reruns @latest
|
|
205
|
+
}
|
|
206
|
+
const check = async () => {
|
|
207
|
+
const latest = await fetchLatest()
|
|
208
|
+
if (latest && isNewer(latest, VERSION)) {
|
|
209
|
+
if (!pendingUpdate) process.stderr.write(`\n ◆ thinkpool-pair ${latest} published — will restart at the next idle moment (all sessions quiet).\n`)
|
|
210
|
+
pendingUpdate = latest
|
|
211
|
+
applyIfIdle()
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
setInterval(check, POLL_MS).unref()
|
|
215
|
+
setInterval(applyIfIdle, 15000).unref()
|
|
216
|
+
setTimeout(check, 60000).unref()
|
|
217
|
+
}
|
|
218
|
+
|
|
155
219
|
await new Promise(() => {})
|
|
156
220
|
}
|
package/bridge.mjs
CHANGED
|
@@ -125,8 +125,12 @@ const argv = process.argv.slice(2)
|
|
|
125
125
|
// Boot-persistent service install (cross-platform: launchd / systemd / Windows
|
|
126
126
|
// Startup). Subcommand form: `thinkpool-pair install-service <ROOM> [-- <cmd>]`.
|
|
127
127
|
if (argv[0] === 'install-service' || argv[0] === 'uninstall-service') {
|
|
128
|
-
|
|
129
|
-
|
|
128
|
+
// `install-service <ROOM>` → supervise one room. `install-service` (no room,
|
|
129
|
+
// or only flags / a leading `--`) → supervise the ACCOUNT bridge, which serves
|
|
130
|
+
// every session you're in. room=null is the account-mode signal into service.mjs.
|
|
131
|
+
const arg1 = argv[1] || ''
|
|
132
|
+
const account = !arg1 || arg1.startsWith('-')
|
|
133
|
+
const svcRoom = account ? null : arg1.toUpperCase().trim()
|
|
130
134
|
const sd = argv.indexOf('--')
|
|
131
135
|
const svcCmd = sd >= 0 ? argv.slice(sd + 1) : []
|
|
132
136
|
const svc = await import('./service.mjs')
|
|
@@ -873,6 +877,18 @@ if (process.env.THINKPOOL_PAIR_AUTOUPDATE === '1' && VERSION) {
|
|
|
873
877
|
setInterval(check, POLL_MS).unref() // poll the registry
|
|
874
878
|
setInterval(applyIfIdle, 15000).unref() // once pending, land it as soon as idle
|
|
875
879
|
setTimeout(check, 60000).unref() // first check after the session settles
|
|
880
|
+
} else if (process.send && process.env.THINKPOOL_PAIR_ACCOUNT_CHILD === '1') {
|
|
881
|
+
// Account-child tier: the account supervisor spawned us over IPC. We do NOT
|
|
882
|
+
// self-restart (AUTOUPDATE is unset for children on purpose) — only the
|
|
883
|
+
// supervisor restart re-resolves @latest. Instead we report our idle state so
|
|
884
|
+
// the supervisor can pick a moment when EVERY child is idle to restart-update.
|
|
885
|
+
const IDLE_MS = Math.max(10, parseInt(process.env.THINKPOOL_PAIR_UPDATE_IDLE, 10) || 90) * 1000
|
|
886
|
+
const idleNow = () => {
|
|
887
|
+
if (Date.now() - lastActivity < IDLE_MS) return false
|
|
888
|
+
for (const t of terms.values()) if (t.buf) return false // bytes mid-flush
|
|
889
|
+
return true
|
|
890
|
+
}
|
|
891
|
+
setInterval(() => { try { process.send({ t: 'idle', idle: idleNow() }) } catch { /* parent gone */ } }, 5000).unref()
|
|
876
892
|
} else if (VERSION) {
|
|
877
893
|
// Foreground run (not the supervised service): it can't self-restart, but
|
|
878
894
|
// nudge once if a newer version is out so a relaunch with @latest picks it up.
|
package/package.json
CHANGED
package/service.mjs
CHANGED
|
@@ -7,6 +7,14 @@
|
|
|
7
7
|
• Windows → Startup-folder .cmd running --supervise
|
|
8
8
|
`--supervise` (in bridge.mjs) is the cross-platform crash-restart
|
|
9
9
|
baseline that needs no install; this adds boot persistence.
|
|
10
|
+
|
|
11
|
+
Two service shapes, one builder:
|
|
12
|
+
• room — `install-service <ROOM>` → supervises one room bridge.
|
|
13
|
+
• account — `install-service` (no room) → supervises the ACCOUNT
|
|
14
|
+
supervisor (`npx thinkpool-pair`, bare), which serves
|
|
15
|
+
every session you're in. Pass room=null for this.
|
|
16
|
+
Both launch `thinkpool-pair@latest`, so a restart pulls the newest
|
|
17
|
+
publish; auto-update self-restart lives in bridge.mjs / account.mjs.
|
|
10
18
|
───────────────────────────────────────────────────────────── */
|
|
11
19
|
|
|
12
20
|
import os from 'node:os'
|
|
@@ -14,7 +22,10 @@ import fs from 'node:fs'
|
|
|
14
22
|
import path from 'node:path'
|
|
15
23
|
import { execSync } from 'node:child_process'
|
|
16
24
|
|
|
17
|
-
|
|
25
|
+
// Service identity. Account mode has no room → a single stable id so there's
|
|
26
|
+
// exactly one account service per machine (a second install replaces it).
|
|
27
|
+
const label = (room) => room ? `io.thinkpool.pair.${room.toLowerCase()}` : 'io.thinkpool.pair.account'
|
|
28
|
+
const slug = (room) => room || 'account'
|
|
18
29
|
const xml = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
19
30
|
|
|
20
31
|
// Resolve the real npx next to the current node, so the service stays seamless:
|
|
@@ -28,15 +39,19 @@ function npxPath(platform) {
|
|
|
28
39
|
}
|
|
29
40
|
|
|
30
41
|
// Pure artifact builder — returned shape is testable without side effects.
|
|
31
|
-
|
|
42
|
+
// room falsy → ACCOUNT service (bare `thinkpool-pair`, auto-serves all sessions).
|
|
43
|
+
export function buildArtifact(platform, { room = null, cmdArgs = [] } = {}) {
|
|
32
44
|
const npx = npxPath(platform)
|
|
33
45
|
const cwd = process.cwd()
|
|
46
|
+
const id = slug(room)
|
|
34
47
|
const logDir = path.join(os.homedir(), '.thinkpool-pair')
|
|
35
|
-
const log = path.join(logDir, `${
|
|
48
|
+
const log = path.join(logDir, `${id}.log`)
|
|
36
49
|
const tail = cmdArgs.length ? ['--', ...cmdArgs] : []
|
|
50
|
+
const desc = room ? `bridge (${room})` : 'account bridge'
|
|
37
51
|
// OS-supervised tiers (launchd KeepAlive / systemd Restart) don't need
|
|
38
52
|
// --supervise; the latest published bridge is fetched by npx each (re)start.
|
|
39
|
-
|
|
53
|
+
// Account mode passes NO room arg — that's what selects the account supervisor.
|
|
54
|
+
const pkgArgs = room ? ['-y', 'thinkpool-pair@latest', room] : ['-y', 'thinkpool-pair@latest']
|
|
40
55
|
|
|
41
56
|
if (platform === 'darwin') {
|
|
42
57
|
const args = [npx, ...pkgArgs, ...tail] // launchd KeepAlive supervises
|
|
@@ -60,7 +75,7 @@ export function buildArtifact(platform, { room, cmdArgs = [] }) {
|
|
|
60
75
|
const args = [npx, ...pkgArgs, ...tail] // systemd Restart=always supervises
|
|
61
76
|
const file = path.join(os.homedir(), '.config', 'systemd', 'user', `${label(room)}.service`)
|
|
62
77
|
const content = `[Unit]
|
|
63
|
-
Description=ThinkPool Code
|
|
78
|
+
Description=ThinkPool Code ${desc}
|
|
64
79
|
After=network-online.target
|
|
65
80
|
|
|
66
81
|
[Service]
|
|
@@ -82,12 +97,15 @@ WantedBy=default.target
|
|
|
82
97
|
if (platform === 'win32') {
|
|
83
98
|
// No native per-user daemon without extra tooling — a Startup-folder script
|
|
84
99
|
// running --supervise gives login-persistence + crash-restart, dependency-free.
|
|
100
|
+
// Account mode ignores --supervise (the account supervisor takes over argv),
|
|
101
|
+
// so it gets login-persistence only — no in-process crash-restart on Windows.
|
|
85
102
|
const startup = path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup')
|
|
86
|
-
const file = path.join(startup, `thinkpool-pair-${
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
103
|
+
const file = path.join(startup, `thinkpool-pair-${id}.cmd`)
|
|
104
|
+
const inner = room
|
|
105
|
+
? ['npx', '-y', 'thinkpool-pair@latest', room, '--supervise', ...tail].join(' ')
|
|
106
|
+
: ['npx', '-y', 'thinkpool-pair@latest'].join(' ')
|
|
107
|
+
const content = `@echo off\r\ntitle thinkpool-pair ${id}\r\n${inner}\r\n`
|
|
108
|
+
return { file, content, logDir, post: [], note: 'Installed to the Startup folder — runs at login' + (room ? ' with --supervise (auto-restart on crash).' : ' (account mode).') + ' Start it now without rebooting by double-clicking the .cmd, or run it from a terminal.' }
|
|
91
109
|
}
|
|
92
110
|
|
|
93
111
|
throw new Error(`unsupported platform: ${platform}`)
|
|
@@ -104,13 +122,15 @@ export function installService(room, cmdArgs = []) {
|
|
|
104
122
|
}
|
|
105
123
|
const autoUpdate = process.platform === 'win32'
|
|
106
124
|
? 'tracks thinkpool-pair@latest — new versions apply on next login/restart.'
|
|
107
|
-
: 'tracks thinkpool-pair@latest — checks for new versions and self-restarts at the next idle moment (
|
|
108
|
-
|
|
125
|
+
: 'tracks thinkpool-pair@latest — checks for new versions and self-restarts at the next idle moment (sessions resume), no re-install.'
|
|
126
|
+
const removeArg = room ? ` ${room}` : ''
|
|
127
|
+
const what = room ? `room ${room}` : 'your account (auto-serves every session)'
|
|
128
|
+
process.stderr.write(` ◆ ${what}\n ◆ ${a.note}\n ◆ ${autoUpdate}\n ◆ logs: ${path.join(a.logDir, `${slug(room)}.log`)}\n ◆ remove with: thinkpool-pair uninstall-service${removeArg}\n\n`)
|
|
109
129
|
}
|
|
110
130
|
|
|
111
|
-
// Is a boot-persistent service already installed
|
|
112
|
-
//
|
|
113
|
-
// starting a second bridge that would race the service
|
|
131
|
+
// Is a boot-persistent service already installed? (artifact file present.)
|
|
132
|
+
// room falsy → checks the ACCOUNT service. Lets the main run skip the offer +
|
|
133
|
+
// avoid starting a second bridge that would race the service.
|
|
114
134
|
export function isServiceInstalled(room) {
|
|
115
135
|
try { return fs.existsSync(buildArtifact(process.platform, { room }).file) } catch { return false }
|
|
116
136
|
}
|