thinkpool-pair 0.7.2 → 0.7.4

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 ADDED
@@ -0,0 +1,148 @@
1
+ /* account.mjs — account-mode bridge (Phase 1).
2
+ One `npx thinkpool-pair` per ACCOUNT instead of per room:
3
+ login — link this device to your ThinkPool account (realtime handoff).
4
+ bind — map a room code → a working directory on THIS machine.
5
+ account — discover your code sessions and serve each one (a headless child
6
+ bridge per room, run in its bound directory). Reuses the proven
7
+ single-room bridge verbatim; the supervisor only adds discovery.
8
+ Spec: docs/specs/2026-06-16-account-bridge.md */
9
+ import { spawn } from 'node:child_process'
10
+ import { fileURLToPath } from 'node:url'
11
+ import { randomUUID } from 'node:crypto'
12
+ import path from 'node:path'
13
+ import fs from 'node:fs'
14
+ import { createClient } from '@supabase/supabase-js'
15
+ import os from 'node:os'
16
+ import { saveAuth, loadAuth, clearAuth, loadDirs, bindDir } from './auth-store.mjs'
17
+
18
+ const VERSION = (() => { try { return JSON.parse(fs.readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version } catch { return null } })()
19
+
20
+ const BRIDGE = fileURLToPath(new URL('./bridge.mjs', import.meta.url))
21
+
22
+ // ── login: OAuth-style DEVICE-CODE flow (hardened) ──────────────────────────
23
+ // The bridge asks Postgres (start_device_code) for a secret device_code + a short
24
+ // human user_code, prints the user_code + URL, then POLLS claim_device_code with
25
+ // the secret until the signed-in web has approved (approve_device_code stores the
26
+ // browser's session). The session is delivered only over authenticated TLS RPCs
27
+ // + a single-use secret — never broadcast on a public channel.
28
+ export async function runLogin(SUPABASE_URL, SUPABASE_ANON, WEB_BASE) {
29
+ const sb = createClient(SUPABASE_URL, SUPABASE_ANON, { auth: { persistSession: false } })
30
+ const { data, error } = await sb.rpc('start_device_code')
31
+ const row = Array.isArray(data) ? data[0] : data
32
+ if (error || !row?.device_code) { process.stderr.write(`\n ✗ couldn't start login: ${error?.message || 'no response'}\n`); process.exit(1) }
33
+ const { device_code, user_code } = row
34
+ const url = `${WEB_BASE}/code/link?c=${encodeURIComponent(user_code)}`
35
+ process.stderr.write(`\n ◆ Link this device to your ThinkPool account.\n Open (signed in) and approve:\n\n ${url}\n\n code: ${user_code}\n\n Waiting…\n`)
36
+ const started = Date.now()
37
+ const poll = async () => {
38
+ if (Date.now() - started > 5 * 60 * 1000) { process.stderr.write('\n ◇ link timed out — run `npx thinkpool-pair login` again.\n'); process.exit(1) }
39
+ try {
40
+ const { data: r } = await sb.rpc('claim_device_code', { p_device_code: device_code })
41
+ if (r?.status === 'approved' && r.session?.refresh_token) {
42
+ saveAuth(r.session)
43
+ process.stderr.write(`\n ◆ linked as ${r.session.email || 'your account'}. You can close the browser tab.\n Now run: npx thinkpool-pair\n`)
44
+ process.exit(0)
45
+ }
46
+ if (r?.status === 'expired' || r?.status === 'not_found') { process.stderr.write('\n ◇ link expired — run `npx thinkpool-pair login` again.\n'); process.exit(1) }
47
+ } catch { /* transient — keep polling */ }
48
+ setTimeout(poll, 3000)
49
+ }
50
+ poll()
51
+ await new Promise(() => {})
52
+ }
53
+
54
+ // Exchange the stored refresh token for a live session + authed client.
55
+ export async function authedClient(SUPABASE_URL, SUPABASE_ANON) {
56
+ const a = loadAuth()
57
+ if (!a?.refresh_token) return null
58
+ const sb = createClient(SUPABASE_URL, SUPABASE_ANON, { auth: { persistSession: false, autoRefreshToken: false } })
59
+ try {
60
+ const { data, error } = await sb.auth.refreshSession({ refresh_token: a.refresh_token })
61
+ if (error || !data?.session) return null
62
+ saveAuth(data.session) // rotate the refresh token
63
+ return { sb, session: data.session }
64
+ } catch { return null }
65
+ }
66
+
67
+ export function runBind(room, dir) {
68
+ const r = (room || '').toUpperCase().trim()
69
+ if (!r) { console.error('usage: npx thinkpool-pair bind <ROOM> <directory>'); process.exit(1) }
70
+ const abs = path.resolve(dir || '.')
71
+ if (!fs.existsSync(abs) || !fs.statSync(abs).isDirectory()) { console.error(`✗ not a directory: ${abs}`); process.exit(1) }
72
+ bindDir(r, abs)
73
+ console.error(`✓ bound ${r} → ${abs}\n Run \`npx thinkpool-pair\` (or restart it) to serve this session.`)
74
+ process.exit(0)
75
+ }
76
+
77
+ // ── account supervisor: a headless child bridge per bound room, in its dir ──
78
+ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
79
+ const auth = await authedClient(SUPABASE_URL, SUPABASE_ANON)
80
+ if (!auth) {
81
+ clearAuth()
82
+ console.error('\n Not linked (or the link expired). Run:\n\n npx thinkpool-pair login\n')
83
+ process.exit(1)
84
+ }
85
+ const { sb, session } = auth
86
+ const email = session.user?.email || 'your account'
87
+
88
+ // Owner-gate (Contract C-CODE-2): hosting a code session — spawning the agent and
89
+ // burning compute on this machine — requires Pro or Enterprise. This is the layer
90
+ // with teeth on cost; the start_code_session RPC gates the web create flow, this
91
+ // stops a crafted-row bypass from ever serving. Invited partners don't run a bridge.
92
+ let plan = 'free'
93
+ try {
94
+ const { data } = await sb.from('profiles').select('plan').eq('id', session.user.id).single()
95
+ plan = data?.plan || 'free'
96
+ } catch { /* transient read — fall through to the gate, fail closed */ }
97
+ if (plan !== 'pro' && plan !== 'enterprise') {
98
+ process.stderr.write(`\n ◇ ThinkPool Code requires a Pro or Enterprise plan to host a session.\n Your account (${email}) is on the ${plan} plan.\n Upgrade: https://thinkpool.io/pro\n\n (Anyone you invite can still join and steer a Pro host's session for free.)\n`)
99
+ process.exit(1)
100
+ }
101
+
102
+ process.stderr.write(`\n ◆ thinkpool-pair — account mode · ${email}\n Serving your code sessions. Add a session's directory with \`bind\`.\n`)
103
+
104
+ const children = new Map() // room -> child process
105
+ const warned = new Set()
106
+
107
+ // Account presence — so the web dashboard can show "● Bridge active" for this
108
+ // account without per-room probing. We TRACK this machine (name + served room
109
+ // codes + version) on a per-account channel; the dashboard subscribes read-only.
110
+ const machine = os.hostname().replace(/\.local$/, '')
111
+ const acct = sb.channel(`tpacct:${session.user.id}`, { config: { presence: { key: machine } } })
112
+ const pushPresence = () => { try { acct.track({ name: machine, version: VERSION, rooms: [...children.keys()], ts: Date.now() }) } catch { /* noop */ } }
113
+ await new Promise((res) => acct.subscribe((st) => { if (st === 'SUBSCRIBED') { pushPresence(); res() } }))
114
+
115
+ const tick = async () => {
116
+ let rooms = []
117
+ try {
118
+ const { data } = await sb.from('code_sessions').select('code,name,last_active_at').order('last_active_at', { ascending: false })
119
+ rooms = data || []
120
+ } catch { /* transient — keep existing children, retry next tick */ }
121
+ const dirs = loadDirs()
122
+ for (const r of rooms) {
123
+ const room = r.code
124
+ if (!room || children.has(room)) continue
125
+ const dir = dirs[room]
126
+ if (!dir) {
127
+ if (!warned.has(room)) { warned.add(room); process.stderr.write(`\n ◇ session ${r.name ? `"${r.name}" ` : ''}(${room}) needs a directory:\n npx thinkpool-pair bind ${room} <path>\n`) }
128
+ continue
129
+ }
130
+ if (!fs.existsSync(dir)) {
131
+ if (!warned.has(room)) { warned.add(room); process.stderr.write(`\n ◇ ${room}: bound directory is gone (${dir}) — re-bind it.\n`) }
132
+ continue
133
+ }
134
+ warned.delete(room)
135
+ process.stderr.write(`\n ◆ serving ${room}${r.name ? ` "${r.name}"` : ''} → ${dir}\n`)
136
+ const child = spawn(process.execPath, [BRIDGE, room, '--headless'], { cwd: dir, stdio: 'inherit' })
137
+ children.set(room, child)
138
+ child.on('exit', () => { children.delete(room) }) // re-served on the next tick
139
+ }
140
+ pushPresence()
141
+ }
142
+
143
+ await tick()
144
+ const iv = setInterval(tick, 15000)
145
+ const stop = (sig) => { clearInterval(iv); try { acct.untrack(); sb.removeChannel(acct) } catch { /* noop */ } for (const c of children.values()) { try { c.kill(sig) } catch { /* noop */ } } setTimeout(() => process.exit(0), 400) }
146
+ for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => stop(sig))
147
+ await new Promise(() => {})
148
+ }
package/auth-store.mjs ADDED
@@ -0,0 +1,43 @@
1
+ /* auth-store.mjs — account-mode credentials + per-session directory map for the
2
+ ThinkPool bridge. Files live under ~/.thinkpool-pair/ (chmod 600 for the token).
3
+ - auth.json: the linked account's Supabase refresh token (rotated on each use).
4
+ - dirs.json: per-MACHINE map of room code → absolute working directory, so one
5
+ account bridge can run each session's terminals in its own repo.
6
+ Phase 1 of the account-bridge spec (docs/specs/2026-06-16-account-bridge.md). */
7
+ import os from 'node:os'
8
+ import fs from 'node:fs'
9
+ import path from 'node:path'
10
+
11
+ const DIR = path.join(os.homedir(), '.thinkpool-pair')
12
+ const AUTH = path.join(DIR, 'auth.json')
13
+ const DIRS = path.join(DIR, 'dirs.json')
14
+
15
+ function ensureDir() { try { fs.mkdirSync(DIR, { recursive: true }) } catch { /* noop */ } }
16
+
17
+ export function saveAuth(session) {
18
+ if (!session?.refresh_token) return
19
+ ensureDir()
20
+ try {
21
+ fs.writeFileSync(AUTH, JSON.stringify({
22
+ refresh_token: session.refresh_token,
23
+ access_token: session.access_token || null,
24
+ email: session.user?.email || session.email || null,
25
+ savedAt: Date.now(),
26
+ }), { mode: 0o600 })
27
+ } catch { /* noop */ }
28
+ }
29
+ export function loadAuth() { try { return JSON.parse(fs.readFileSync(AUTH, 'utf8')) } catch { return null } }
30
+ export function clearAuth() { try { fs.unlinkSync(AUTH) } catch { /* noop */ } }
31
+
32
+ export function loadDirs() { try { return JSON.parse(fs.readFileSync(DIRS, 'utf8')) } catch { return {} } }
33
+ export function bindDir(room, dir) {
34
+ const d = loadDirs(); d[room] = dir
35
+ ensureDir()
36
+ try { fs.writeFileSync(DIRS, JSON.stringify(d, null, 2)) } catch { /* noop */ }
37
+ return d
38
+ }
39
+ export function unbindDir(room) {
40
+ const d = loadDirs(); delete d[room]
41
+ try { fs.writeFileSync(DIRS, JSON.stringify(d, null, 2)) } catch { /* noop */ }
42
+ return d
43
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.2",
3
+ "version": "0.7.4",
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": {
@@ -11,6 +11,8 @@
11
11
  "claude-session.mjs",
12
12
  "session-store.mjs",
13
13
  "service.mjs",
14
+ "account.mjs",
15
+ "auth-store.mjs",
14
16
  "README.md"
15
17
  ],
16
18
  "scripts": {