thinkpool-pair 0.6.8 → 0.6.10

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
@@ -18,6 +18,31 @@ node bridge.mjs <ROOM> # picks an installed agent, shared into room <ROOM>
18
18
  - Share a specific CLI: `node bridge.mjs <ROOM> -- aider` / `… -- bash`.
19
19
  - Pure relay (no local terminal): `node bridge.mjs <ROOM> --headless`.
20
20
 
21
+ ## Keep it stable (auto-restart)
22
+
23
+ The bridge self-heals at three levels — all cross-platform (macOS / Linux / Windows):
24
+
25
+ - **`--supervise`** — wrap it so a crash respawns automatically (exponential
26
+ backoff). Zero-dependency. Combined with session restore (below), a crash is
27
+ invisible — the room reconnects and the Claude session resumes where it left off.
28
+ ```bash
29
+ npx thinkpool-pair <ROOM> --supervise -- claude
30
+ ```
31
+ - **Boot-persistent service** — survive reboot/logout too. Installs the right
32
+ native service for your OS (launchd on macOS, systemd `--user` on Linux, a
33
+ Startup-folder script on Windows):
34
+ ```bash
35
+ npx thinkpool-pair install-service <ROOM> -- claude # set and forget
36
+ npx thinkpool-pair uninstall-service <ROOM> # remove it
37
+ ```
38
+ - **Watchdog** — if the realtime channel wedges for >60s, the bridge exits so the
39
+ supervisor/service restarts a clean process. Brief network blips reconnect on
40
+ their own.
41
+
42
+ Structured Claude sessions persist their scrollback + session id to
43
+ `~/.thinkpool-pair/<ROOM>/` and **resume on restart** (live context if recent),
44
+ so none of the above loses your place.
45
+
21
46
  Your first agent runs **attached** — use it exactly like your normal terminal,
22
47
  every byte mirrors to the web. The web's **"+ New terminal"** spawns additional
23
48
  **headless** terminals here (same directory, same env), driven entirely from
package/bridge.mjs CHANGED
@@ -32,6 +32,7 @@ import os from 'node:os'
32
32
  import fs from 'node:fs'
33
33
  import path from 'node:path'
34
34
  import readline from 'node:readline'
35
+ import { spawn } from 'node:child_process'
35
36
  import { randomUUID } from 'node:crypto'
36
37
  import { createClient } from '@supabase/supabase-js'
37
38
  import { startClaudeSession } from './claude-session.mjs'
@@ -48,11 +49,13 @@ const SCROLLBACK_MAX = 120_000
48
49
 
49
50
  // node-pty is a native module — loaded lazily so a friendly message shows
50
51
  // if it isn't built yet.
51
- let pty
52
+ // Structured Claude (the default) runs through the Agent SDK and needs NO PTY,
53
+ // so a failed node-pty build must not block the bridge — it only disables the
54
+ // raw-PTY path (other CLIs / TP_PTY=1), which we flag clearly if it's used.
55
+ let pty = null
52
56
  try { pty = (await import('node-pty')).default ?? (await import('node-pty')) }
53
57
  catch {
54
- console.error('\n node-pty isn\'t installed. From the bridge folder run:\n npm i\n then re-run. (it needs a C toolchain Xcode CLT on mac, build-essential on linux)\n')
55
- process.exit(1)
58
+ console.error('\n node-pty not built raw terminal sharing is off (structured Claude still works).\n For PTY mode (bash / aider / codex / TP_PTY=1): install a C toolchain, then `npm i` in the bridge.\n (Xcode CLT on mac · build-essential on linux · VS Build Tools on Windows.)\n')
56
59
  }
57
60
 
58
61
  // ── Known coding-agent CLIs we feature in the picker ──────────────
@@ -117,8 +120,51 @@ const pickAgent = (installed) => new Promise((resolve) => {
117
120
  })
118
121
 
119
122
  const argv = process.argv.slice(2)
123
+
124
+ // Boot-persistent service install (cross-platform: launchd / systemd / Windows
125
+ // Startup). Subcommand form: `thinkpool-pair install-service <ROOM> [-- <cmd>]`.
126
+ if (argv[0] === 'install-service' || argv[0] === 'uninstall-service') {
127
+ const svcRoom = (argv[1] || '').toUpperCase().trim()
128
+ if (!svcRoom || svcRoom.startsWith('-')) { console.error('usage: thinkpool-pair install-service <ROOM> [-- <command…>]'); process.exit(1) }
129
+ const sd = argv.indexOf('--')
130
+ const svcCmd = sd >= 0 ? argv.slice(sd + 1) : []
131
+ const svc = await import('./service.mjs')
132
+ if (argv[0] === 'install-service') svc.installService(svcRoom, svcCmd)
133
+ else svc.uninstallService(svcRoom)
134
+ process.exit(0)
135
+ }
136
+
120
137
  const room = (argv[0] || '').toUpperCase().trim()
121
138
  if (!room || room.startsWith('-')) { console.error('usage: npx thinkpool-pair <ROOM> [--headless] [--continue|--fresh] [-- <command…>]'); process.exit(1) }
139
+ // ── Supervisor mode (--supervise / --keep-alive): keep the bridge alive across
140
+ // crashes. We re-exec ourselves without the flag and respawn the child on any
141
+ // non-clean exit with exponential backoff. Zero-dependency, cross-platform. With
142
+ // the crash-resume in session-store, a respawn restores the session invisibly.
143
+ // (Boot persistence — surviving reboot/logout — is the launchd/systemd tier.)
144
+ const _superDash = argv.indexOf('--')
145
+ const _superOwn = _superDash >= 0 ? argv.slice(0, _superDash) : argv
146
+ if (_superOwn.includes('--supervise') || _superOwn.includes('--keep-alive')) {
147
+ const childArgs = process.argv.slice(1).filter((a) => a !== '--supervise' && a !== '--keep-alive')
148
+ let backoff = 1000, stopping = false, child = null
149
+ const spawnChild = () => {
150
+ const t0 = Date.now()
151
+ child = spawn(process.execPath, childArgs, { stdio: 'inherit' })
152
+ child.on('exit', (code, signal) => {
153
+ if (stopping || code === 0) process.exit(code || 0) // clean stop → stop supervising
154
+ const ranLong = Date.now() - t0 > 60000
155
+ const wait = ranLong ? 1000 : backoff
156
+ backoff = ranLong ? 1000 : Math.min(backoff * 2, 30000)
157
+ process.stderr.write(`\n ⚠ bridge exited (${signal || `code ${code}`}); restarting in ${Math.round(wait / 1000)}s…\n`)
158
+ setTimeout(spawnChild, wait)
159
+ })
160
+ child.on('error', () => { /* spawn failure → exit handler retries */ })
161
+ }
162
+ for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => { stopping = true; try { child?.kill(sig) } catch { /* noop */ } setTimeout(() => process.exit(0), 400) })
163
+ process.stderr.write(`\n ◆ supervisor up — keeping the bridge for room ${room} alive (auto-restart on crash).\n`)
164
+ spawnChild()
165
+ await new Promise(() => {}) // block forever; the child IS the bridge
166
+ }
167
+
122
168
  const headless = argv.includes('--headless')
123
169
  // Structured mode (Phase 2, opt-in): Claude Code runs through the Agent SDK
124
170
  // (structured events + risk-tiered permission gate) instead of the PTY byte
@@ -281,6 +327,11 @@ const attachedDims = () => ({
281
327
 
282
328
  function openTerm({ id, cmd, args = [], attached = false, cols, rows }) {
283
329
  if (terms.has(id)) return
330
+ if (!pty) { // raw-PTY path needs node-pty; structured Claude never lands here
331
+ process.stderr.write(`\n ⚠ can't open "${cmd}" — node-pty isn't built (PTY mode disabled).\n`)
332
+ bcast('term-exit', { id })
333
+ return
334
+ }
284
335
  const ad = attached ? attachedDims() : null
285
336
  const term = pty.spawn(cmd, args, {
286
337
  name: 'xterm-256color',
@@ -421,6 +472,11 @@ process.stdout.on('resize', () => {
421
472
  try { t.term.resize(cols, rows); announce() } catch { /* noop */ }
422
473
  })
423
474
 
475
+ // Realtime health, read by the subscribe handler + watchdog (declared before
476
+ // the channel chain so the async subscribe callback closes over live bindings).
477
+ let realtimeHealthy = false
478
+ let brokenSince = Date.now()
479
+
424
480
  channel
425
481
  .on('broadcast', { event: 'pty-in' }, ({ payload }) => {
426
482
  if (!payload?.data) return
@@ -553,6 +609,7 @@ channel
553
609
  .on('broadcast', { event: 'who' }, announce)
554
610
  .subscribe(status => {
555
611
  if (status === 'SUBSCRIBED') {
612
+ realtimeHealthy = true; brokenSince = 0
556
613
  channel.track({ name, role: 'bridge' })
557
614
  if (attachedCmd && !terms.size && !sessions.size) {
558
615
  // Claude + structured mode → Agent SDK session; everything else → PTY.
@@ -569,9 +626,26 @@ channel
569
626
  process.stderr.write(headless
570
627
  ? `\n ◆ thinkpool — relaying room ${room} (headless). Open terminals from the web UI.\n\n`
571
628
  : `\n ◆ thinkpool — sharing "${attachedCmd}"${continuing ? ' (continuing your latest session)' : ''} into room ${room}. Open the web UI and you're both in.\n\n`)
629
+ } else if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT' || status === 'CLOSED') {
630
+ // supabase-js retries the socket itself; we just track health for the
631
+ // watchdog. A blip self-heals (next SUBSCRIBED re-announces); a wedge that
632
+ // never recovers trips the watchdog → exit → supervisor respawns clean.
633
+ if (realtimeHealthy || !brokenSince) brokenSince = Date.now()
634
+ realtimeHealthy = false
635
+ process.stderr.write(`\n ⚠ realtime ${status} — reconnecting…\n`)
572
636
  }
573
637
  })
574
638
 
639
+ // Watchdog — if the realtime channel is stuck (never connected, or dropped and
640
+ // not recovered) for >60s, exit non-zero so a supervisor/launchd restarts a
641
+ // clean process. Healthy long-lived connections never trip this (brokenSince=0).
642
+ setInterval(() => {
643
+ if (!realtimeHealthy && brokenSince && Date.now() - brokenSince > 60000) {
644
+ process.stderr.write('\n ⚠ realtime wedged >60s — exiting for a clean restart.\n')
645
+ process.exit(1)
646
+ }
647
+ }, 15000).unref()
648
+
575
649
  function shutdown() {
576
650
  if (shuttingDown) return
577
651
  shuttingDown = true
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.6.8",
3
+ "version": "0.6.10",
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": {
@@ -10,6 +10,7 @@
10
10
  "bridge.mjs",
11
11
  "claude-session.mjs",
12
12
  "session-store.mjs",
13
+ "service.mjs",
13
14
  "README.md"
14
15
  ],
15
16
  "scripts": {
@@ -20,7 +21,9 @@
20
21
  },
21
22
  "dependencies": {
22
23
  "@anthropic-ai/claude-agent-sdk": "^0.3.173",
23
- "@supabase/supabase-js": "^2.45.0",
24
+ "@supabase/supabase-js": "^2.45.0"
25
+ },
26
+ "optionalDependencies": {
24
27
  "node-pty": "^1.2.0-beta.13"
25
28
  }
26
29
  }
package/service.mjs ADDED
@@ -0,0 +1,103 @@
1
+ /* ─────────────────────────────────────────────────────────────
2
+ service.mjs — install the bridge as a boot-persistent OS service
3
+ so a customer's bridge survives reboot/logout and auto-restarts
4
+ on crash, on every platform:
5
+ • macOS → launchd LaunchAgent (KeepAlive)
6
+ • Linux → systemd --user unit (Restart=always)
7
+ • Windows → Startup-folder .cmd running --supervise
8
+ `--supervise` (in bridge.mjs) is the cross-platform crash-restart
9
+ baseline that needs no install; this adds boot persistence.
10
+ ───────────────────────────────────────────────────────────── */
11
+
12
+ import os from 'node:os'
13
+ import fs from 'node:fs'
14
+ import path from 'node:path'
15
+ import { fileURLToPath } from 'node:url'
16
+ import { execSync } from 'node:child_process'
17
+
18
+ const BRIDGE = fileURLToPath(new URL('./bridge.mjs', import.meta.url))
19
+ const label = (room) => `io.thinkpool.pair.${room.toLowerCase()}`
20
+ const xml = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
21
+
22
+ // Pure artifact builder — returned shape is testable without side effects.
23
+ // `mode:'service'` lets the OS supervise (no --supervise); 'supervise' wraps it.
24
+ export function buildArtifact(platform, { room, cmdArgs = [] }) {
25
+ const node = process.execPath
26
+ const cwd = process.cwd()
27
+ const logDir = path.join(os.homedir(), '.thinkpool-pair')
28
+ const log = path.join(logDir, `${room}.log`)
29
+ const tail = cmdArgs.length ? ['--', ...cmdArgs] : []
30
+
31
+ if (platform === 'darwin') {
32
+ const args = [node, BRIDGE, room, ...tail] // launchd KeepAlive supervises
33
+ const file = path.join(os.homedir(), 'Library', 'LaunchAgents', `${label(room)}.plist`)
34
+ const content = `<?xml version="1.0" encoding="UTF-8"?>
35
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
36
+ <plist version="1.0"><dict>
37
+ <key>Label</key><string>${label(room)}</string>
38
+ <key>ProgramArguments</key><array>${args.map((a) => `<string>${xml(a)}</string>`).join('')}</array>
39
+ <key>RunAtLoad</key><true/>
40
+ <key>KeepAlive</key><true/>
41
+ <key>WorkingDirectory</key><string>${xml(cwd)}</string>
42
+ <key>StandardOutPath</key><string>${xml(log)}</string>
43
+ <key>StandardErrorPath</key><string>${xml(log)}</string>
44
+ <key>EnvironmentVariables</key><dict><key>PATH</key><string>${xml(process.env.PATH || '')}</string></dict>
45
+ </dict></plist>\n`
46
+ 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.' }
47
+ }
48
+
49
+ if (platform === 'linux') {
50
+ const args = [node, BRIDGE, room, ...tail] // systemd Restart=always supervises
51
+ const file = path.join(os.homedir(), '.config', 'systemd', 'user', `${label(room)}.service`)
52
+ const content = `[Unit]
53
+ Description=ThinkPool Code bridge (${room})
54
+ After=network-online.target
55
+
56
+ [Service]
57
+ ExecStart=${args.map((a) => `'${a.replace(/'/g, "'\\''")}'`).join(' ')}
58
+ Restart=always
59
+ RestartSec=2
60
+ WorkingDirectory=${cwd}
61
+ Environment=PATH=${process.env.PATH || ''}
62
+ StandardOutput=append:${log}
63
+ StandardError=append:${log}
64
+
65
+ [Install]
66
+ WantedBy=default.target
67
+ `
68
+ return { file, content, logDir, post: ['systemctl --user daemon-reload', `systemctl --user enable --now ${label(room)}.service`], note: 'Enabled as a systemd --user service. Run `loginctl enable-linger $USER` once to keep it running after logout.' }
69
+ }
70
+
71
+ if (platform === 'win32') {
72
+ // No native per-user daemon without extra tooling — a Startup-folder script
73
+ // running --supervise gives login-persistence + crash-restart, dependency-free.
74
+ const startup = path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup')
75
+ const file = path.join(startup, `thinkpool-pair-${room}.cmd`)
76
+ const inner = [`"${node}"`, `"${BRIDGE}"`, room, '--supervise', ...tail].join(' ')
77
+ const content = `@echo off\r\ntitle thinkpool-pair ${room}\r\n${inner}\r\n`
78
+ 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.' }
79
+ }
80
+
81
+ throw new Error(`unsupported platform: ${platform}`)
82
+ }
83
+
84
+ export function installService(room, cmdArgs = []) {
85
+ const a = buildArtifact(process.platform, { room, cmdArgs })
86
+ fs.mkdirSync(a.logDir, { recursive: true })
87
+ fs.mkdirSync(path.dirname(a.file), { recursive: true })
88
+ fs.writeFileSync(a.file, a.content)
89
+ process.stderr.write(`\n ◆ wrote ${a.file}\n`)
90
+ for (const cmd of a.post) {
91
+ try { execSync(cmd, { stdio: 'inherit' }) } catch (e) { process.stderr.write(` ⚠ "${cmd}" failed: ${e.message}\n`) }
92
+ }
93
+ process.stderr.write(` ◆ ${a.note}\n ◆ logs: ${path.join(a.logDir, `${room}.log`)}\n ◆ remove with: thinkpool-pair uninstall-service ${room}\n\n`)
94
+ }
95
+
96
+ export function uninstallService(room) {
97
+ const plat = process.platform
98
+ let a
99
+ try { a = buildArtifact(plat, { room }) } catch (e) { process.stderr.write(` ⚠ ${e.message}\n`); return }
100
+ if (plat === 'darwin') { try { execSync(`launchctl unload "${a.file}" 2>/dev/null`) } catch { /* noop */ } }
101
+ if (plat === 'linux') { try { execSync(`systemctl --user disable --now ${label(room)}.service 2>/dev/null`) } catch { /* noop */ } }
102
+ try { fs.rmSync(a.file, { force: true }); process.stderr.write(` ◆ removed ${a.file}\n`) } catch (e) { process.stderr.write(` ⚠ ${e.message}\n`) }
103
+ }