thinkpool-pair 0.7.14 → 0.7.16

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 CHANGED
@@ -13,7 +13,7 @@ import path from 'node:path'
13
13
  import fs from 'node:fs'
14
14
  import { createClient } from '@supabase/supabase-js'
15
15
  import os from 'node:os'
16
- import { saveAuth, loadAuth, clearAuth, loadDirs, bindDir } from './auth-store.mjs'
16
+ import { saveAuth, loadAuth, loadDirs, bindDir } from './auth-store.mjs'
17
17
 
18
18
  const VERSION = (() => { try { return JSON.parse(fs.readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version } catch { return null } })()
19
19
 
@@ -78,8 +78,13 @@ export function runBind(room, dir) {
78
78
  export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
79
79
  const auth = await authedClient(SUPABASE_URL, SUPABASE_ANON)
80
80
  if (!auth) {
81
- clearAuth()
82
- console.error('\n Not linked (or the link expired). Run:\n\n npx thinkpool-pair login\n')
81
+ // Do NOT delete auth.json here. A refresh can fail transiently (offline) or
82
+ // lose a refresh-token rotation race with another bridge sharing this login
83
+ // wiping the file on that destroys a still-valid login. Leave it: only an
84
+ // explicit `login` should ever replace the token (saveAuth overwrites it).
85
+ // (2026-06-17: a second account supervisor raced the token and the old
86
+ // clearAuth() here deleted a live Pro login mid-session.)
87
+ console.error('\n ◇ Couldn\'t refresh your account session (offline, or another bridge\n may be using this login). Your saved login was left in place.\n If this keeps happening, re-link: npx thinkpool-pair login\n')
83
88
  process.exit(1)
84
89
  }
85
90
  const { sb, session } = auth
@@ -100,9 +105,13 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
100
105
  }
101
106
 
102
107
  const DEFAULT_DIR = process.cwd() // unbound sessions auto-serve here; `bind` overrides per session
103
- 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`)
108
+ const svcHint = process.env.THINKPOOL_PAIR_AUTOUPDATE === '1'
109
+ ? ' (auto-updating background service — survives reboot, tracks @latest)\n'
110
+ : ' (run hands-off + auto-updating: npx thinkpool-pair install-service)\n'
111
+ 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
112
 
105
113
  const children = new Map() // room -> child process
114
+ const childIdle = new Map() // room -> bool (last idle report; unknown = not idle)
106
115
  const warned = new Set()
107
116
 
108
117
  // Account presence — so the web dashboard can show "● Bridge active" for this
@@ -132,9 +141,16 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
132
141
  }
133
142
  warned.delete(room)
134
143
  process.stderr.write(`\n ◆ serving ${room}${r.name ? ` "${r.name}"` : ''} → ${dir}${dirs[room] ? '' : ' (default)'}\n`)
135
- const child = spawn(process.execPath, [BRIDGE, room, '--headless', '--auto=claude'], { cwd: dir, stdio: 'inherit' })
144
+ // Children NEVER self-update (only the supervisor restart re-resolves
145
+ // @latest); they report idle to us over IPC so we can restart at a moment
146
+ // when every session is quiet. Strip AUTOUPDATE so an inherited service env
147
+ // can't make a child self-exit; flag it as an account child instead.
148
+ const env = { ...process.env, THINKPOOL_PAIR_ACCOUNT_CHILD: '1' }
149
+ delete env.THINKPOOL_PAIR_AUTOUPDATE
150
+ const child = spawn(process.execPath, [BRIDGE, room, '--headless', '--auto=claude'], { cwd: dir, stdio: ['inherit', 'inherit', 'inherit', 'ipc'], env })
136
151
  children.set(room, child)
137
- child.on('exit', () => { children.delete(room) }) // re-served on the next tick
152
+ child.on('message', (m) => { if (m && m.t === 'idle') childIdle.set(room, !!m.idle) })
153
+ child.on('exit', () => { children.delete(room); childIdle.delete(room) }) // re-served on the next tick
138
154
  }
139
155
  pushPresence()
140
156
  }
@@ -152,5 +168,58 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
152
168
  setTimeout(() => process.exit(0), 1500) // hard backstop if the flush hangs
153
169
  }
154
170
  for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.on(sig, () => stop(sig))
171
+
172
+ // ── auto-update (account-service tier only) ───────────────────────────────
173
+ // launchd (KeepAlive) / systemd (Restart=always) re-run `npx -y
174
+ // thinkpool-pair@latest` (no room) on every restart, so a clean exit here
175
+ // pulls the newest publish — and because the new supervisor re-spawns children
176
+ // from the newly-resolved bridge, the room children get the new version too
177
+ // (they can't self-update; only this restart propagates it). Gated to
178
+ // THINKPOOL_PAIR_AUTOUPDATE=1 (set by the account service installer). We wait
179
+ // for EVERY child to report idle so no one's mid-keystroke when we cycle; each
180
+ // child persists + resumes its session, so the restart is invisible in-room.
181
+ // THINKPOOL_PAIR_UPDATE_INTERVAL registry poll seconds (default 1800)
182
+ // THINKPOOL_PAIR_UPDATE_IDLE child idle seconds before a restart (default 90)
183
+ if (process.env.THINKPOOL_PAIR_AUTOUPDATE === '1' && VERSION) {
184
+ const POLL_MS = Math.max(60, parseInt(process.env.THINKPOOL_PAIR_UPDATE_INTERVAL, 10) || 1800) * 1000
185
+ let pendingUpdate = null
186
+ // Strictly-newer x.y.z core; ignore prerelease tails (never downgrade/churn).
187
+ const isNewer = (latest, cur) => {
188
+ const core = (v) => String(v).split('-')[0].split('.').map((n) => parseInt(n, 10) || 0)
189
+ const a = core(latest), b = core(cur)
190
+ 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 }
191
+ return false
192
+ }
193
+ const fetchLatest = async () => {
194
+ const ctrl = new AbortController()
195
+ const to = setTimeout(() => ctrl.abort(), 8000)
196
+ try {
197
+ const r = await fetch('https://registry.npmjs.org/thinkpool-pair/latest', { signal: ctrl.signal, headers: { accept: 'application/json' } })
198
+ if (!r.ok) return null
199
+ const j = await r.json()
200
+ return typeof j?.version === 'string' ? j.version : null
201
+ } catch { return null } finally { clearTimeout(to) }
202
+ }
203
+ // Idle iff every served child has reported idle. No children → trivially idle.
204
+ // Unknown (no report yet) counts as NOT idle, so we never cycle a fresh child.
205
+ const allIdle = () => { for (const room of children.keys()) if (!childIdle.get(room)) return false; return true }
206
+ const applyIfIdle = () => {
207
+ if (!pendingUpdate || stopping || !allIdle()) return
208
+ process.stderr.write(`\n ◆ thinkpool-pair ${pendingUpdate} ready (running ${VERSION}) — restarting account bridge to update; sessions resume.\n`)
209
+ stop('SIGTERM') // graceful: children persist + leave presence; exit 0 → service reruns @latest
210
+ }
211
+ const check = async () => {
212
+ const latest = await fetchLatest()
213
+ if (latest && isNewer(latest, VERSION)) {
214
+ if (!pendingUpdate) process.stderr.write(`\n ◆ thinkpool-pair ${latest} published — will restart at the next idle moment (all sessions quiet).\n`)
215
+ pendingUpdate = latest
216
+ applyIfIdle()
217
+ }
218
+ }
219
+ setInterval(check, POLL_MS).unref()
220
+ setInterval(applyIfIdle, 15000).unref()
221
+ setTimeout(check, 60000).unref()
222
+ }
223
+
155
224
  await new Promise(() => {})
156
225
  }
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
- const svcRoom = (argv[1] || '').toUpperCase().trim()
129
- if (!svcRoom || svcRoom.startsWith('-')) { console.error('usage: thinkpool-pair install-service <ROOM> [-- <command…>]'); process.exit(1) }
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.14",
3
+ "version": "0.7.16",
4
4
  "description": "Share a local coding-agent CLI (Claude Code, Codex, Gemini, Aider, …) into a ThinkPool Code room, live.",
5
5
  "type": "module",
6
6
  "bin": {
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
- const label = (room) => `io.thinkpool.pair.${room.toLowerCase()}`
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
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
- export function buildArtifact(platform, { room, cmdArgs = [] }) {
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, `${room}.log`)
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
- const pkgArgs = ['-y', 'thinkpool-pair@latest', room]
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 bridge (${room})
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-${room}.cmd`)
87
- // Startup folder isn't a supervisor → keep --supervise for crash-restart.
88
- const inner = ['npx', '-y', 'thinkpool-pair@latest', room, '--supervise', ...tail].join(' ')
89
- const content = `@echo off\r\ntitle thinkpool-pair ${room}\r\n${inner}\r\n`
90
- return { file, content, logDir, post: [], note: 'Installed to the Startup folder — runs at login with --supervise (auto-restart on crash). Start it now without rebooting by double-clicking the .cmd, or run it from a terminal.' }
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 (session resumes), no re-install.'
108
- process.stderr.write(` ◆ ${a.note}\n ◆ ${autoUpdate}\n ◆ logs: ${path.join(a.logDir, `${room}.log`)}\n ◆ remove with: thinkpool-pair uninstall-service ${room}\n\n`)
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 for this room? (artifact file
112
- // present.) Lets the main `npx thinkpool-pair <room>` run skip the offer + avoid
113
- // starting a second bridge that would race the service on the same room.
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
  }