thinkpool-pair 0.6.16 → 0.6.17

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/README.md CHANGED
@@ -58,19 +58,29 @@ tunnel. So a cloud VM, container, devcontainer, or remote dev box can stream its
58
58
  Claude Code into the room exactly like a laptop. Run the same command there:
59
59
 
60
60
  ```bash
61
- export ANTHROPIC_API_KEY=sk-ant-... # headless auth (no Keychain login)
61
+ export ANTHROPIC_API_KEY=sk-ant-... # headless auth (no Keychain login)
62
62
  cd /path/to/your/repo
63
- npx thinkpool-pair <ROOM> -- claude # structured Claude, no TTY needed
63
+ npx thinkpool-pair@latest <ROOM> -- claude # structured Claude, no TTY needed
64
64
  ```
65
65
 
66
66
  - **Auth**: set `ANTHROPIC_API_KEY` (or `CLAUDE_CODE_OAUTH_TOKEN`) in the box's
67
67
  env — the Agent SDK reads it automatically. The structured path runs the SDK's
68
68
  bundled `claude`, so you don't need Claude Code separately installed.
69
+ - **Use `@latest`**: a bare `npx thinkpool-pair` reuses npx's local cache and can
70
+ run a stale version across restarts. `@latest` (and the service below) always
71
+ re-resolve the newest publish.
69
72
  - **No TTY required**: the agent picker auto-selects and raw-mode is skipped when
70
73
  there's no terminal, so it runs fine under CI / a service / `nohup`.
71
- - **Always-on**: `npx thinkpool-pair install-service <ROOM> -- claude` installs a
72
- systemd `--user` unit (auto-restart + boot-persistent + auto-update). On a
73
- server, also run `loginctl enable-linger $USER` so it survives logout.
74
+ - **Always-on + auto-update**: `npx thinkpool-pair@latest install-service <ROOM> -- claude`
75
+ installs a systemd `--user` unit (auto-restart + boot-persistent). The bridge
76
+ polls npm for new publishes and **self-restarts at the next idle moment** into
77
+ the new version — the structured session resumes, so an away/unattended box
78
+ upgrades itself with nobody losing their place. On a server, also run
79
+ `loginctl enable-linger $USER` so it survives logout.
80
+ Tune with `THINKPOOL_PAIR_UPDATE_INTERVAL` (poll seconds, default 1800) and
81
+ `THINKPOOL_PAIR_UPDATE_IDLE` (idle seconds before restart, default 90). Live
82
+ auto-update applies to the launchd/systemd service tiers; bare `--supervise`
83
+ respawns the same version, so it updates only on the next manual/boot restart.
74
84
  - **Security**: anyone with the room code can drive the agent (shell-trust by
75
85
  design) — on a server that's a real shell, so treat the room code like a secret.
76
86
 
package/bridge.mjs CHANGED
@@ -260,7 +260,14 @@ let shuttingDown = false
260
260
  // straight into the host's ATTACHED terminal, shredding the TUI
261
261
  // (2026-06-11). Same delivery, explicit path, no warning: httpSend when
262
262
  // the socket is down. Loss on total offline is fine — scrollback replays.
263
+ // Activity clock — the most recent moment the room was doing something (agent
264
+ // output or a human driving). Read by the auto-update poll so an update restart
265
+ // only happens when idle, never mid-turn.
266
+ let lastActivity = Date.now()
267
+ const markActivity = () => { lastActivity = Date.now() }
268
+
263
269
  const bcast = (event, payload) => {
270
+ if (event === 'pty-out' || event === 'code-event') lastActivity = Date.now()
264
271
  try {
265
272
  if (channel.channelAdapter?.canPush?.() ?? true) {
266
273
  channel.send({ type: 'broadcast', event, payload })
@@ -486,6 +493,7 @@ let brokenSince = Date.now()
486
493
  channel
487
494
  .on('broadcast', { event: 'pty-in' }, ({ payload }) => {
488
495
  if (!payload?.data) return
496
+ markActivity()
489
497
  const t = terms.get(payload.term) || (payload.term == null && attachedId ? terms.get(attachedId) : null)
490
498
  if (t) t.term.write(payload.data)
491
499
  })
@@ -569,6 +577,7 @@ channel
569
577
  .on('broadcast', { event: 'code-turn' }, ({ payload }) => {
570
578
  const s = payload?.term && sessions.get(payload.term)
571
579
  if (!s || payload.text == null) return
580
+ markActivity()
572
581
  const text = String(payload.text)
573
582
  // Echo the turn so BOTH readers (and late joiners, via the log) show who
574
583
  // said what — UNLESS silent (an @pool synthesis, which renders its own line).
@@ -652,6 +661,61 @@ setInterval(() => {
652
661
  }
653
662
  }, 15000).unref()
654
663
 
664
+ // ── auto-update (OS-service tier only) ─────────────────────────────────
665
+ // launchd (KeepAlive) and systemd (Restart=always) re-run
666
+ // `npx -y thinkpool-pair@latest` on every (re)start, so a clean exit here makes
667
+ // the supervisor pull the newest publish; session-store then resumes the
668
+ // session, so the update is invisible to both people in the room. Gated to
669
+ // THINKPOOL_PAIR_AUTOUPDATE=1 (set by the service installer) because under bare
670
+ // `--supervise` the supervisor respawns the SAME version (exit 0 there just
671
+ // stops it) — self-exiting would loop or kill the bridge, not update it.
672
+ // THINKPOOL_PAIR_UPDATE_INTERVAL registry poll seconds (default 1800)
673
+ // THINKPOOL_PAIR_UPDATE_IDLE idle seconds before a restart (default 90)
674
+ if (process.env.THINKPOOL_PAIR_AUTOUPDATE === '1' && VERSION) {
675
+ const POLL_MS = Math.max(60, parseInt(process.env.THINKPOOL_PAIR_UPDATE_INTERVAL, 10) || 1800) * 1000
676
+ const IDLE_MS = Math.max(10, parseInt(process.env.THINKPOOL_PAIR_UPDATE_IDLE, 10) || 90) * 1000
677
+ let pendingUpdate = null // the newer version string, once seen
678
+
679
+ const isIdle = () => {
680
+ if (Date.now() - lastActivity < IDLE_MS) return false
681
+ for (const t of terms.values()) if (t.buf) return false // bytes mid-flush
682
+ return true
683
+ }
684
+ // Newer? Compare the numeric x.y.z core; ignore prerelease tails to stay safe.
685
+ const isNewer = (latest, cur) => {
686
+ const core = (v) => String(v).split('-')[0].split('.').map((n) => parseInt(n, 10) || 0)
687
+ const a = core(latest), b = core(cur)
688
+ 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 }
689
+ return false
690
+ }
691
+ const fetchLatest = async () => {
692
+ const ctrl = new AbortController()
693
+ const to = setTimeout(() => ctrl.abort(), 8000)
694
+ try {
695
+ const r = await fetch('https://registry.npmjs.org/thinkpool-pair/latest', { signal: ctrl.signal, headers: { accept: 'application/json' } })
696
+ if (!r.ok) return null
697
+ const j = await r.json()
698
+ return typeof j?.version === 'string' ? j.version : null
699
+ } catch { return null } finally { clearTimeout(to) }
700
+ }
701
+ const applyIfIdle = () => {
702
+ if (!pendingUpdate || !isIdle()) return
703
+ process.stderr.write(`\n ◆ thinkpool-pair ${pendingUpdate} ready (running ${VERSION}) — restarting to update; the session resumes.\n`)
704
+ process.exit(0) // service restarts with @latest; session-store resumes
705
+ }
706
+ const check = async () => {
707
+ const latest = await fetchLatest()
708
+ if (latest && isNewer(latest, VERSION)) {
709
+ if (!pendingUpdate) process.stderr.write(`\n ◆ thinkpool-pair ${latest} published — will restart at the next idle moment.\n`)
710
+ pendingUpdate = latest
711
+ applyIfIdle()
712
+ }
713
+ }
714
+ setInterval(check, POLL_MS).unref() // poll the registry
715
+ setInterval(applyIfIdle, 15000).unref() // once pending, land it as soon as idle
716
+ setTimeout(check, 60000).unref() // first check after the session settles
717
+ }
718
+
655
719
  function shutdown() {
656
720
  if (shuttingDown) return
657
721
  shuttingDown = true
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.6.16",
3
+ "version": "0.6.17",
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
@@ -51,7 +51,7 @@ export function buildArtifact(platform, { room, cmdArgs = [] }) {
51
51
  <key>WorkingDirectory</key><string>${xml(cwd)}</string>
52
52
  <key>StandardOutPath</key><string>${xml(log)}</string>
53
53
  <key>StandardErrorPath</key><string>${xml(log)}</string>
54
- <key>EnvironmentVariables</key><dict><key>PATH</key><string>${xml(process.env.PATH || '')}</string></dict>
54
+ <key>EnvironmentVariables</key><dict><key>PATH</key><string>${xml(process.env.PATH || '')}</string><key>THINKPOOL_PAIR_AUTOUPDATE</key><string>1</string></dict>
55
55
  </dict></plist>\n`
56
56
  return { file, content, logDir, post: [`launchctl unload "${file}" 2>/dev/null; launchctl load "${file}"`], note: 'Loaded as a LaunchAgent — starts at login, restarts on crash.' }
57
57
  }
@@ -69,6 +69,7 @@ Restart=always
69
69
  RestartSec=2
70
70
  WorkingDirectory=${cwd}
71
71
  Environment=PATH=${process.env.PATH || ''}
72
+ Environment=THINKPOOL_PAIR_AUTOUPDATE=1
72
73
  StandardOutput=append:${log}
73
74
  StandardError=append:${log}
74
75
 
@@ -101,7 +102,10 @@ export function installService(room, cmdArgs = []) {
101
102
  for (const cmd of a.post) {
102
103
  try { execSync(cmd, { stdio: 'inherit' }) } catch (e) { process.stderr.write(` ⚠ "${cmd}" failed: ${e.message}\n`) }
103
104
  }
104
- process.stderr.write(` ◆ ${a.note}\n ◆ tracks thinkpool-pair@latest — new versions apply on next restart, no re-install.\n ◆ logs: ${path.join(a.logDir, `${room}.log`)}\n ◆ remove with: thinkpool-pair uninstall-service ${room}\n\n`)
105
+ const autoUpdate = process.platform === 'win32'
106
+ ? '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`)
105
109
  }
106
110
 
107
111
  export function uninstallService(room) {