thinkpool-pair 0.1.1 → 0.3.1
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 +19 -9
- package/bridge.mjs +207 -38
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -5,27 +5,37 @@ room, live. Both people see the real terminal stream and can both drive it —
|
|
|
5
5
|
no copy/paste.
|
|
6
6
|
|
|
7
7
|
> A web page can't read your terminal directly (browser security). This tiny
|
|
8
|
-
> helper is the bridge: it runs
|
|
8
|
+
> helper is the bridge: it runs CLIs in PTYs and streams them to the room.
|
|
9
9
|
|
|
10
10
|
## Run it (one command)
|
|
11
11
|
|
|
12
12
|
```bash
|
|
13
13
|
cd bridge && npm i # one-time (builds node-pty — needs Xcode CLT / build-essential)
|
|
14
|
-
node bridge.mjs <ROOM> #
|
|
14
|
+
node bridge.mjs <ROOM> # picks an installed agent, shared into room <ROOM>
|
|
15
15
|
```
|
|
16
16
|
|
|
17
17
|
- `<ROOM>` is the 5-char room code from the ThinkPool Code web UI (`/code`).
|
|
18
|
-
- Share
|
|
19
|
-
-
|
|
20
|
-
|
|
21
|
-
|
|
18
|
+
- Share a specific CLI: `node bridge.mjs <ROOM> -- aider` / `… -- bash`.
|
|
19
|
+
- Pure relay (no local terminal): `node bridge.mjs <ROOM> --headless`.
|
|
20
|
+
|
|
21
|
+
Your first agent runs **attached** — use it exactly like your normal terminal,
|
|
22
|
+
every byte mirrors to the web. The web's **"+ New terminal"** spawns additional
|
|
23
|
+
**headless** terminals here (same directory, same env), driven entirely from
|
|
24
|
+
the room. One bridge, many terminals.
|
|
22
25
|
|
|
23
26
|
## How it works
|
|
24
27
|
|
|
25
28
|
`bridge.mjs` ⇄ **Supabase realtime** (`tpcode:<ROOM>`) ⇄ web `xterm`:
|
|
26
|
-
- `
|
|
27
|
-
- `pty-
|
|
28
|
-
- `
|
|
29
|
+
- `bridge` — announce: installed agents + live terminals.
|
|
30
|
+
- `pty-out` — terminal bytes (base64, per-terminal) → web clients render them.
|
|
31
|
+
- `pty-in` — keystrokes/prompts from the web → written to that terminal's PTY.
|
|
32
|
+
- `term-open` / `term-close` / `term-exit` — web-driven terminal lifecycle.
|
|
33
|
+
- `replay-request` / `pty-replay` — each terminal keeps a rolling ~120 KB
|
|
34
|
+
scrollback buffer; joining/reloading clients get it replayed, so the room
|
|
35
|
+
never opens blank while the bridge is up. **Nothing is stored server-side** —
|
|
36
|
+
history lives exactly as long as the bridge runs.
|
|
37
|
+
- `resize` — web viewport size → headless PTYs only (the attached terminal
|
|
38
|
+
follows your own TTY).
|
|
29
39
|
|
|
30
40
|
Public anon creds are embedded (the same ones the web app ships). Override with
|
|
31
41
|
`TP_SUPABASE_URL` / `TP_SUPABASE_ANON` if needed. Set `TP_NAME` to label yourself.
|
package/bridge.mjs
CHANGED
|
@@ -3,17 +3,26 @@
|
|
|
3
3
|
thinkpool-pair — share your local Claude (or any CLI) into a
|
|
4
4
|
ThinkPool Code room, live. ONE command, seamless:
|
|
5
5
|
|
|
6
|
-
npx thinkpool-pair <ROOM>
|
|
7
|
-
npx thinkpool-pair <ROOM> -- <cmd…>
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
6
|
+
npx thinkpool-pair <ROOM> # auto-detects your agents, pick one
|
|
7
|
+
npx thinkpool-pair <ROOM> -- <cmd…> # share a specific CLI (skip the picker)
|
|
8
|
+
npx thinkpool-pair <ROOM> --headless # pure relay — terminals open from the web
|
|
9
|
+
|
|
10
|
+
v0.3 — multi-terminal. The bridge is your machine's presence in the
|
|
11
|
+
room; terminals are things you open inside it. Your first agent runs
|
|
12
|
+
ATTACHED (your own terminal, exactly as before). The web's
|
|
13
|
+
"+ New terminal" spawns additional HEADLESS PTYs here — same cwd,
|
|
14
|
+
same env — and every terminal streams to the room over Supabase
|
|
15
|
+
realtime. Each terminal keeps a rolling scrollback buffer that
|
|
16
|
+
replays to anyone who joins or reloads, so the room never opens
|
|
17
|
+
blank while the bridge is up. Nothing is stored server-side.
|
|
18
|
+
Spec: docs/specs/2026-06-10-code-multi-terminal.md
|
|
14
19
|
───────────────────────────────────────────────────────────── */
|
|
15
20
|
|
|
16
21
|
import os from 'node:os'
|
|
22
|
+
import fs from 'node:fs'
|
|
23
|
+
import path from 'node:path'
|
|
24
|
+
import readline from 'node:readline'
|
|
25
|
+
import { randomUUID } from 'node:crypto'
|
|
17
26
|
import { createClient } from '@supabase/supabase-js'
|
|
18
27
|
|
|
19
28
|
// Public client creds (the same anon values the web app ships — safe to embed).
|
|
@@ -21,6 +30,10 @@ import { createClient } from '@supabase/supabase-js'
|
|
|
21
30
|
const SUPABASE_URL = process.env.TP_SUPABASE_URL || 'https://daytvtakmlixpfbbqzjd.supabase.co'
|
|
22
31
|
const SUPABASE_ANON = process.env.TP_SUPABASE_ANON || 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImRheXR2dGFrbWxpeHBmYmJxempkIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzgwNTMxNzEsImV4cCI6MjA5MzYyOTE3MX0.zYmz8bHUNEY4STUr2JuXChAsKwxNwkwFHe1Hd0Betqo'
|
|
23
32
|
|
|
33
|
+
// Per-terminal rolling scrollback (raw chars). Replayed to joining clients.
|
|
34
|
+
// Kept comfortably under realtime payload limits once base64'd.
|
|
35
|
+
const SCROLLBACK_MAX = 120_000
|
|
36
|
+
|
|
24
37
|
// node-pty is a native module — loaded lazily so a friendly message shows
|
|
25
38
|
// if it isn't built yet.
|
|
26
39
|
let pty
|
|
@@ -30,13 +43,81 @@ catch {
|
|
|
30
43
|
process.exit(1)
|
|
31
44
|
}
|
|
32
45
|
|
|
46
|
+
// ── Known coding-agent CLIs we feature in the picker ──────────────
|
|
47
|
+
// Order = display order; Claude Code stays first (the original default). The
|
|
48
|
+
// picker only offers the ones actually installed (PATH probe below). Power
|
|
49
|
+
// users can bypass it entirely with `-- <any command>`.
|
|
50
|
+
const KNOWN_AGENTS = [
|
|
51
|
+
{ label: 'Claude Code', cmd: 'claude' },
|
|
52
|
+
{ label: 'Codex CLI', cmd: 'codex' },
|
|
53
|
+
{ label: 'Gemini CLI', cmd: 'gemini' },
|
|
54
|
+
{ label: 'Aider', cmd: 'aider' },
|
|
55
|
+
{ label: 'Cursor CLI', cmd: 'cursor-agent' },
|
|
56
|
+
{ label: 'opencode', cmd: 'opencode' },
|
|
57
|
+
{ label: 'Copilot CLI', cmd: 'copilot' },
|
|
58
|
+
{ label: 'Goose', cmd: 'goose' },
|
|
59
|
+
{ label: 'Crush', cmd: 'crush' },
|
|
60
|
+
{ label: 'Qwen Code', cmd: 'qwen' },
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
// Is `c` on PATH? Pure-Node scan (no `which` subprocess), cross-platform.
|
|
64
|
+
const onPath = (c) => {
|
|
65
|
+
const exts = process.platform === 'win32'
|
|
66
|
+
? (process.env.PATHEXT || '.EXE;.CMD;.BAT;.COM').split(';')
|
|
67
|
+
: ['']
|
|
68
|
+
for (const dir of (process.env.PATH || '').split(path.delimiter)) {
|
|
69
|
+
if (!dir) continue
|
|
70
|
+
for (const ext of exts) {
|
|
71
|
+
try { if (fs.existsSync(path.join(dir, c + ext))) return true } catch { /* noop */ }
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return false
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Pick which installed agent to share → resolves to a command string. A single
|
|
78
|
+
// match (or non-TTY stdin, e.g. piped) auto-selects with no prompt.
|
|
79
|
+
const pickAgent = (installed) => new Promise((resolve) => {
|
|
80
|
+
if (installed.length === 1 || !process.stdin.isTTY) { resolve(installed[0].cmd); return }
|
|
81
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stderr })
|
|
82
|
+
process.stderr.write('\n Which coding agent do you want to share?\n\n')
|
|
83
|
+
installed.forEach((a, i) => process.stderr.write(` ${i + 1}) ${a.label}\n`))
|
|
84
|
+
process.stderr.write('\n')
|
|
85
|
+
rl.question(' Pick a number [1]: ', (ans) => {
|
|
86
|
+
rl.close()
|
|
87
|
+
const n = parseInt(String(ans).trim(), 10)
|
|
88
|
+
const idx = Number.isInteger(n) && n >= 1 && n <= installed.length ? n - 1 : 0
|
|
89
|
+
resolve(installed[idx].cmd)
|
|
90
|
+
})
|
|
91
|
+
})
|
|
92
|
+
|
|
33
93
|
const argv = process.argv.slice(2)
|
|
34
94
|
const room = (argv[0] || '').toUpperCase().trim()
|
|
35
|
-
if (!room) { console.error('usage: npx thinkpool-pair <ROOM> [-- <command…>]'); process.exit(1) }
|
|
95
|
+
if (!room || room.startsWith('-')) { console.error('usage: npx thinkpool-pair <ROOM> [--headless] [-- <command…>]'); process.exit(1) }
|
|
96
|
+
const headless = argv.includes('--headless')
|
|
97
|
+
const installedAgents = KNOWN_AGENTS.filter(a => onPath(a.cmd))
|
|
36
98
|
const dashIdx = argv.indexOf('--')
|
|
37
|
-
|
|
99
|
+
let attachedCmd = null, attachedArgs = []
|
|
100
|
+
if (dashIdx >= 0) {
|
|
101
|
+
;[attachedCmd, ...attachedArgs] = argv.slice(dashIdx + 1)
|
|
102
|
+
} else if (!headless) {
|
|
103
|
+
if (installedAgents.length === 0) {
|
|
104
|
+
console.error(`\n No known coding-agent CLI found on your PATH.\n Install one (claude / codex / gemini / aider / cursor-agent / opencode …)\n or share a specific command: npx thinkpool-pair ${room} -- <your-command>\n`)
|
|
105
|
+
process.exit(1)
|
|
106
|
+
}
|
|
107
|
+
attachedCmd = await pickAgent(installedAgents)
|
|
108
|
+
}
|
|
38
109
|
const name = process.env.TP_NAME || os.userInfo().username || 'host'
|
|
39
110
|
|
|
111
|
+
// Repo awareness — the room shows which project this machine is sharing.
|
|
112
|
+
// Cheap reads, no subprocess: directory name + .git/HEAD.
|
|
113
|
+
const cwd = process.cwd()
|
|
114
|
+
const repoLabel = path.basename(cwd)
|
|
115
|
+
let branch = null
|
|
116
|
+
try {
|
|
117
|
+
const head = fs.readFileSync(path.join(cwd, '.git', 'HEAD'), 'utf8').trim()
|
|
118
|
+
branch = head.startsWith('ref:') ? head.split('/').pop() : head.slice(0, 7)
|
|
119
|
+
} catch { /* not a git repo — fine */ }
|
|
120
|
+
|
|
40
121
|
const supabase = createClient(SUPABASE_URL, SUPABASE_ANON, {
|
|
41
122
|
realtime: { params: { eventsPerSecond: 60 } },
|
|
42
123
|
})
|
|
@@ -44,52 +125,140 @@ const channel = supabase.channel(`tpcode:${room}`, {
|
|
|
44
125
|
config: { broadcast: { self: false }, presence: { key: `bridge:${name}` } },
|
|
45
126
|
})
|
|
46
127
|
|
|
47
|
-
// ──
|
|
48
|
-
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
})
|
|
128
|
+
// ── terminal registry ──────────────────────────────────────────────
|
|
129
|
+
// id → { term (pty), cmd, attached, scrollback, buf }
|
|
130
|
+
const terms = new Map()
|
|
131
|
+
let attachedId = null
|
|
132
|
+
let shuttingDown = false
|
|
53
133
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
134
|
+
const announce = () =>
|
|
135
|
+
channel.send({ type: 'broadcast', event: 'bridge', payload: {
|
|
136
|
+
v: 2, name, repo: repoLabel, branch,
|
|
137
|
+
agents: installedAgents,
|
|
138
|
+
terms: [...terms.entries()].map(([id, t]) => ({ id, cmd: t.cmd, alive: true })),
|
|
139
|
+
} })
|
|
140
|
+
|
|
141
|
+
// One flush timer batches every terminal's pending bytes (~35ms cadence).
|
|
142
|
+
const flushAll = () => {
|
|
143
|
+
for (const [id, t] of terms) {
|
|
144
|
+
if (!t.buf) continue
|
|
145
|
+
const b64 = Buffer.from(t.buf, 'utf8').toString('base64')
|
|
146
|
+
t.buf = ''
|
|
147
|
+
channel.send({ type: 'broadcast', event: 'pty-out', payload: { term: id, b64 } })
|
|
148
|
+
}
|
|
66
149
|
}
|
|
67
|
-
const flushTimer = setInterval(
|
|
150
|
+
const flushTimer = setInterval(flushAll, 35)
|
|
68
151
|
|
|
69
|
-
|
|
70
|
-
|
|
152
|
+
function openTerm({ id, cmd, args = [], attached = false, cols, rows }) {
|
|
153
|
+
if (terms.has(id)) return
|
|
154
|
+
const term = pty.spawn(cmd, args, {
|
|
155
|
+
name: 'xterm-256color',
|
|
156
|
+
cols: cols || (attached ? (process.stdout.columns || 100) : 100),
|
|
157
|
+
rows: rows || (attached ? (process.stdout.rows || 30) : 30),
|
|
158
|
+
cwd: process.cwd(), env: process.env,
|
|
159
|
+
})
|
|
160
|
+
const entry = { term, cmd, attached, scrollback: '', buf: '' }
|
|
161
|
+
terms.set(id, entry)
|
|
162
|
+
if (attached) attachedId = id
|
|
163
|
+
term.onData(d => {
|
|
164
|
+
if (attached) process.stdout.write(d)
|
|
165
|
+
entry.buf += d
|
|
166
|
+
entry.scrollback = (entry.scrollback + d).slice(-SCROLLBACK_MAX)
|
|
167
|
+
})
|
|
168
|
+
term.onExit(() => {
|
|
169
|
+
terms.delete(id)
|
|
170
|
+
if (id === attachedId) { attachedId = null; detachLocal() }
|
|
171
|
+
if (shuttingDown) return
|
|
172
|
+
try { channel.send({ type: 'broadcast', event: 'term-exit', payload: { id } }) } catch { /* noop */ }
|
|
173
|
+
announce()
|
|
174
|
+
if (terms.size === 0 && !headless) shutdown()
|
|
175
|
+
else process.stderr.write(`\n ◇ terminal "${cmd}" ended — still relaying ${terms.size} terminal(s). Ctrl-C to stop.\n`)
|
|
176
|
+
})
|
|
177
|
+
announce()
|
|
178
|
+
return entry
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// After the attached CLI exits, the host's stdin stops feeding a PTY —
|
|
182
|
+
// restore the cooked terminal so Ctrl-C reaches the bridge itself.
|
|
183
|
+
const detachLocal = () => {
|
|
184
|
+
try { if (process.stdin.isTTY) process.stdin.setRawMode(false) } catch { /* noop */ }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// host's own keystrokes → the attached CLI (so it's seamless locally too)
|
|
188
|
+
if (attachedCmd && process.stdin.isTTY) process.stdin.setRawMode(true)
|
|
189
|
+
process.stdin.resume()
|
|
190
|
+
process.stdin.on('data', d => {
|
|
191
|
+
const t = attachedId && terms.get(attachedId)
|
|
192
|
+
if (t) t.term.write(d.toString('utf8'))
|
|
193
|
+
else if (d.includes(3)) shutdown() // Ctrl-C once detached/headless
|
|
194
|
+
})
|
|
195
|
+
// attached terminal follows the host's TTY size (web resize never touches it)
|
|
196
|
+
process.stdout.on('resize', () => {
|
|
197
|
+
const t = attachedId && terms.get(attachedId)
|
|
198
|
+
if (t) { try { t.term.resize(process.stdout.columns, process.stdout.rows) } catch { /* noop */ } }
|
|
199
|
+
})
|
|
71
200
|
|
|
72
201
|
channel
|
|
73
|
-
.on('broadcast', { event: 'pty-in' }, ({ payload }) => {
|
|
202
|
+
.on('broadcast', { event: 'pty-in' }, ({ payload }) => {
|
|
203
|
+
if (!payload?.data) return
|
|
204
|
+
const t = terms.get(payload.term) || (payload.term == null && attachedId ? terms.get(attachedId) : null)
|
|
205
|
+
if (t) t.term.write(payload.data)
|
|
206
|
+
})
|
|
74
207
|
.on('broadcast', { event: 'resize' }, ({ payload }) => {
|
|
75
|
-
|
|
208
|
+
// headless terms are web-sized (last writer wins); the attached term is host-sized.
|
|
209
|
+
if (!payload?.cols || !payload?.rows) return
|
|
210
|
+
const t = terms.get(payload.term)
|
|
211
|
+
if (t && !t.attached) { try { t.term.resize(payload.cols, payload.rows) } catch { /* noop */ } }
|
|
212
|
+
})
|
|
213
|
+
.on('broadcast', { event: 'term-open' }, ({ payload }) => {
|
|
214
|
+
if (!payload?.id || !payload?.cmd) return
|
|
215
|
+
// Multi-bridge rooms: a targeted open is for ONE machine. Untargeted
|
|
216
|
+
// opens (older web) are taken by whoever hears them — the solo case.
|
|
217
|
+
if (payload.host && payload.host !== name) return
|
|
218
|
+
openTerm({ id: payload.id, cmd: payload.cmd })
|
|
219
|
+
process.stderr.write(`\n ◆ web opened a "${payload.cmd}" terminal (headless).\n`)
|
|
220
|
+
})
|
|
221
|
+
.on('broadcast', { event: 'term-close' }, ({ payload }) => {
|
|
222
|
+
const t = payload?.id && terms.get(payload.id)
|
|
223
|
+
if (t && !t.attached) { try { t.term.kill() } catch { /* noop */ } }
|
|
224
|
+
})
|
|
225
|
+
.on('broadcast', { event: 'replay-request' }, ({ payload }) => {
|
|
226
|
+
// Flush pending bytes first so the replay is complete up to "now"; both
|
|
227
|
+
// ride the same ordered socket, so the client sees replay-then-live.
|
|
228
|
+
flushAll()
|
|
229
|
+
for (const [id, t] of terms) {
|
|
230
|
+
if (!t.scrollback) continue
|
|
231
|
+
channel.send({ type: 'broadcast', event: 'pty-replay', payload: {
|
|
232
|
+
to: payload?.to ?? null, term: id,
|
|
233
|
+
b64: Buffer.from(t.scrollback, 'utf8').toString('base64'),
|
|
234
|
+
} })
|
|
235
|
+
}
|
|
236
|
+
announce()
|
|
76
237
|
})
|
|
77
238
|
.on('broadcast', { event: 'who' }, announce)
|
|
78
239
|
.subscribe(status => {
|
|
79
240
|
if (status === 'SUBSCRIBED') {
|
|
80
241
|
channel.track({ name, role: 'bridge' })
|
|
242
|
+
if (attachedCmd && !terms.size) openTerm({ id: randomUUID(), cmd: attachedCmd, args: attachedArgs, attached: true })
|
|
81
243
|
announce()
|
|
82
|
-
process.stderr.write(
|
|
244
|
+
process.stderr.write(headless
|
|
245
|
+
? `\n ◆ thinkpool — relaying room ${room} (headless). Open terminals from the web UI.\n\n`
|
|
246
|
+
: `\n ◆ thinkpool — sharing "${attachedCmd}" into room ${room}. Open the web UI and you're both in.\n\n`)
|
|
83
247
|
}
|
|
84
248
|
})
|
|
85
249
|
|
|
86
|
-
|
|
250
|
+
function shutdown() {
|
|
251
|
+
if (shuttingDown) return
|
|
252
|
+
shuttingDown = true
|
|
87
253
|
clearInterval(flushTimer)
|
|
88
|
-
try {
|
|
254
|
+
try {
|
|
255
|
+
const bye = Buffer.from('\r\n[ shared session ended ]\r\n', 'utf8').toString('base64')
|
|
256
|
+
for (const id of terms.keys()) channel.send({ type: 'broadcast', event: 'pty-out', payload: { term: id, b64: bye } })
|
|
257
|
+
} catch { /* noop */ }
|
|
89
258
|
try { supabase.removeChannel(channel) } catch { /* noop */ }
|
|
90
|
-
try { term.kill() } catch { /* noop */ }
|
|
259
|
+
for (const t of terms.values()) { try { t.term.kill() } catch { /* noop */ } }
|
|
260
|
+
detachLocal()
|
|
91
261
|
process.exit(0)
|
|
92
262
|
}
|
|
93
|
-
term.onExit(shutdown)
|
|
94
263
|
process.on('SIGINT', shutdown)
|
|
95
264
|
process.on('SIGTERM', shutdown)
|
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "thinkpool-pair",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Share
|
|
3
|
+
"version": "0.3.1",
|
|
4
|
+
"description": "Share a local coding-agent CLI (Claude Code, Codex, Gemini, Aider, …) into a ThinkPool Code room, live.",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"bin": "bridge.mjs",
|
|
6
|
+
"bin": { "thinkpool-pair": "bridge.mjs" },
|
|
7
7
|
"engines": {
|
|
8
8
|
"node": ">=18"
|
|
9
9
|
},
|