thinkpool-pair 0.7.1 → 0.7.3

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,140 @@
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: web hands the bridge a session over an ephemeral realtime channel ──
23
+ // The bridge prints a one-time URL with a high-entropy code; the signed-in web
24
+ // page broadcasts {refresh_token,…} to tplink:<code> after the user approves.
25
+ // (Phase-1 handoff — an edge-function device-code flow can harden this later.)
26
+ export async function runLogin(SUPABASE_URL, SUPABASE_ANON, WEB_BASE) {
27
+ const sb = createClient(SUPABASE_URL, SUPABASE_ANON, { auth: { persistSession: false } })
28
+ const code = randomUUID().replace(/-/g, '').slice(0, 12)
29
+ const ch = sb.channel(`tplink:${code}`, { config: { broadcast: { self: false } } })
30
+ let done = false
31
+ ch.on('broadcast', { event: 'session' }, async ({ payload }) => {
32
+ if (done || !payload?.refresh_token) return
33
+ done = true
34
+ saveAuth(payload)
35
+ process.stderr.write(`\n ◆ linked as ${payload.email || 'your account'}. You can close the browser tab.\n Now run: npx thinkpool-pair\n`)
36
+ try { await sb.removeChannel(ch) } catch { /* noop */ }
37
+ process.exit(0)
38
+ })
39
+ await new Promise((res) => ch.subscribe((s) => { if (s === 'SUBSCRIBED') res() }))
40
+ const url = `${WEB_BASE}/code/link?c=${code}`
41
+ process.stderr.write(`\n ◆ Link this device to your ThinkPool account.\n Open (signed in) and approve:\n\n ${url}\n\n Code: ${code} — waiting up to 5 min…\n`)
42
+ setTimeout(() => { if (!done) { process.stderr.write('\n ◇ link timed out — run `npx thinkpool-pair login` again.\n'); process.exit(1) } }, 5 * 60 * 1000)
43
+ await new Promise(() => {})
44
+ }
45
+
46
+ // Exchange the stored refresh token for a live session + authed client.
47
+ export async function authedClient(SUPABASE_URL, SUPABASE_ANON) {
48
+ const a = loadAuth()
49
+ if (!a?.refresh_token) return null
50
+ const sb = createClient(SUPABASE_URL, SUPABASE_ANON, { auth: { persistSession: false, autoRefreshToken: false } })
51
+ try {
52
+ const { data, error } = await sb.auth.refreshSession({ refresh_token: a.refresh_token })
53
+ if (error || !data?.session) return null
54
+ saveAuth(data.session) // rotate the refresh token
55
+ return { sb, session: data.session }
56
+ } catch { return null }
57
+ }
58
+
59
+ export function runBind(room, dir) {
60
+ const r = (room || '').toUpperCase().trim()
61
+ if (!r) { console.error('usage: npx thinkpool-pair bind <ROOM> <directory>'); process.exit(1) }
62
+ const abs = path.resolve(dir || '.')
63
+ if (!fs.existsSync(abs) || !fs.statSync(abs).isDirectory()) { console.error(`✗ not a directory: ${abs}`); process.exit(1) }
64
+ bindDir(r, abs)
65
+ console.error(`✓ bound ${r} → ${abs}\n Run \`npx thinkpool-pair\` (or restart it) to serve this session.`)
66
+ process.exit(0)
67
+ }
68
+
69
+ // ── account supervisor: a headless child bridge per bound room, in its dir ──
70
+ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
71
+ const auth = await authedClient(SUPABASE_URL, SUPABASE_ANON)
72
+ if (!auth) {
73
+ clearAuth()
74
+ console.error('\n Not linked (or the link expired). Run:\n\n npx thinkpool-pair login\n')
75
+ process.exit(1)
76
+ }
77
+ const { sb, session } = auth
78
+ const email = session.user?.email || 'your account'
79
+
80
+ // Owner-gate (Contract C-CODE-2): hosting a code session — spawning the agent and
81
+ // burning compute on this machine — requires Pro or Enterprise. This is the layer
82
+ // with teeth on cost; the start_code_session RPC gates the web create flow, this
83
+ // stops a crafted-row bypass from ever serving. Invited partners don't run a bridge.
84
+ let plan = 'free'
85
+ try {
86
+ const { data } = await sb.from('profiles').select('plan').eq('id', session.user.id).single()
87
+ plan = data?.plan || 'free'
88
+ } catch { /* transient read — fall through to the gate, fail closed */ }
89
+ if (plan !== 'pro' && plan !== 'enterprise') {
90
+ 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`)
91
+ process.exit(1)
92
+ }
93
+
94
+ process.stderr.write(`\n ◆ thinkpool-pair — account mode · ${email}\n Serving your code sessions. Add a session's directory with \`bind\`.\n`)
95
+
96
+ const children = new Map() // room -> child process
97
+ const warned = new Set()
98
+
99
+ // Account presence — so the web dashboard can show "● Bridge active" for this
100
+ // account without per-room probing. We TRACK this machine (name + served room
101
+ // codes + version) on a per-account channel; the dashboard subscribes read-only.
102
+ const machine = os.hostname().replace(/\.local$/, '')
103
+ const acct = sb.channel(`tpacct:${session.user.id}`, { config: { presence: { key: machine } } })
104
+ const pushPresence = () => { try { acct.track({ name: machine, version: VERSION, rooms: [...children.keys()], ts: Date.now() }) } catch { /* noop */ } }
105
+ await new Promise((res) => acct.subscribe((st) => { if (st === 'SUBSCRIBED') { pushPresence(); res() } }))
106
+
107
+ const tick = async () => {
108
+ let rooms = []
109
+ try {
110
+ const { data } = await sb.from('code_sessions').select('code,name,last_active_at').order('last_active_at', { ascending: false })
111
+ rooms = data || []
112
+ } catch { /* transient — keep existing children, retry next tick */ }
113
+ const dirs = loadDirs()
114
+ for (const r of rooms) {
115
+ const room = r.code
116
+ if (!room || children.has(room)) continue
117
+ const dir = dirs[room]
118
+ if (!dir) {
119
+ 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`) }
120
+ continue
121
+ }
122
+ if (!fs.existsSync(dir)) {
123
+ if (!warned.has(room)) { warned.add(room); process.stderr.write(`\n ◇ ${room}: bound directory is gone (${dir}) — re-bind it.\n`) }
124
+ continue
125
+ }
126
+ warned.delete(room)
127
+ process.stderr.write(`\n ◆ serving ${room}${r.name ? ` "${r.name}"` : ''} → ${dir}\n`)
128
+ const child = spawn(process.execPath, [BRIDGE, room, '--headless'], { cwd: dir, stdio: 'inherit' })
129
+ children.set(room, child)
130
+ child.on('exit', () => { children.delete(room) }) // re-served on the next tick
131
+ }
132
+ pushPresence()
133
+ }
134
+
135
+ await tick()
136
+ const iv = setInterval(tick, 15000)
137
+ 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) }
138
+ for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => stop(sig))
139
+ await new Promise(() => {})
140
+ }
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.1",
3
+ "version": "0.7.3",
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": {