thinkpool-pair 0.7.13 → 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 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.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`)
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
- const child = spawn(process.execPath, [BRIDGE, room, '--headless', '--auto=claude'], { cwd: dir, stdio: 'inherit' })
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('exit', () => { children.delete(room) }) // re-served on the next tick
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
@@ -36,7 +36,7 @@ import { spawn } from 'node:child_process'
36
36
  import { randomUUID } from 'node:crypto'
37
37
  import { createClient } from '@supabase/supabase-js'
38
38
  import { startClaudeSession } from './claude-session.mjs'
39
- import { saveSession, flushSession, loadLatest, canResume, loadPtyId, savePtyId } from './session-store.mjs'
39
+ import { saveSession, flushSession, loadAll, canResume, loadPtyId, savePtyId } from './session-store.mjs'
40
40
 
41
41
  // Public client creds (the same anon values the web app ships — safe to embed).
42
42
  // Override with TP_SUPABASE_URL / TP_SUPABASE_ANON if you ever need to.
@@ -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')
@@ -776,11 +780,15 @@ channel
776
780
  const startCmd = attachedCmd || autoAgent // autoAgent: account-mode auto-open (headless)
777
781
  if (startCmd && !terms.size && !sessions.size) {
778
782
  // Claude + structured mode → Agent SDK session; everything else → PTY.
779
- // On restart, restore the latest saved structured session for this room:
780
- // replay its transcript + resume the live SDK context if recent enough.
783
+ // On restart, restore EVERY saved structured session for this room (not
784
+ // just the latest) — replay each transcript + resume its live SDK context
785
+ // when recent enough. Without this, backgrounded terminals died on a
786
+ // bridge restart even though their state was on disk. Bounded by KEEP=8;
787
+ // only runs on a fresh start (guard above), so a network re-subscribe
788
+ // with live sessions won't re-spawn them.
781
789
  if (wantStructured(startCmd)) {
782
- const prev = loadLatest(room)
783
- if (prev && (prev.log?.length || prev.sessionId)) openStructured({ id: prev.id, resume: canResume(prev) ? prev.sessionId : undefined, log: prev.log, commands: prev.commands, mode: prev.mode })
790
+ const all = loadAll(room).filter((r) => r.log?.length || r.sessionId)
791
+ if (all.length) for (const rec of all) openStructured({ id: rec.id, resume: canResume(rec) ? rec.sessionId : undefined, log: rec.log, commands: rec.commands, mode: rec.mode })
784
792
  else openStructured({ id: randomUUID() })
785
793
  }
786
794
  else {
@@ -869,6 +877,18 @@ if (process.env.THINKPOOL_PAIR_AUTOUPDATE === '1' && VERSION) {
869
877
  setInterval(check, POLL_MS).unref() // poll the registry
870
878
  setInterval(applyIfIdle, 15000).unref() // once pending, land it as soon as idle
871
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()
872
892
  } else if (VERSION) {
873
893
  // Foreground run (not the supervised service): it can't self-restart, but
874
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.13",
3
+ "version": "0.7.15",
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
  }
package/session-store.mjs CHANGED
@@ -56,6 +56,14 @@ export function canResume(rec) {
56
56
  return !!(rec && rec.sessionId && rec.savedAt && (Date.now() - rec.savedAt) < RESUME_MAX_AGE_MS)
57
57
  }
58
58
 
59
+ // EVERY saved session for the room, oldest→newest (so tabs reappear in order).
60
+ // Drives resume-ALL on a bridge restart — without this only the latest session
61
+ // came back and every backgrounded terminal died on restart despite its state
62
+ // sitting on disk. Bounded by KEEP (prune keeps the newest 8).
63
+ export function loadAll(room) {
64
+ return listRecs(room).sort((a, b) => (a.savedAt || 0) - (b.savedAt || 0))
65
+ }
66
+
59
67
  // Last PTY terminal id auto-opened for an attached command. A reconnecting
60
68
  // bridge reuses it so it re-attaches to the SAME terminal tab instead of
61
69
  // minting a fresh UUID and breeding a new dormant "Terminal N" each restart.