thinkpool-pair 0.2.0 → 0.3.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.
Files changed (3) hide show
  1. package/README.md +19 -9
  2. package/bridge.mjs +174 -45
  3. package/package.json +2 -2
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 the CLI in a PTY and streams it to the room.
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> # launches `claude`, shared into room <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 any other CLI: `node bridge.mjs <ROOM> -- aider` / `… -- bash`.
19
- - Use the terminal exactly as normal it's seamless locally, and every byte
20
- mirrors to the web for your partner. Prompts/keys typed in the web come back
21
- into your session.
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
- - `pty-out` — terminal bytes (base64) both web clients render them.
27
- - `pty-in` keystrokes/prompts from the webwritten to your PTY.
28
- - `resize` web viewport size`term.resize`.
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,20 +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> # auto-detects your agents, pick one
7
- npx thinkpool-pair <ROOM> -- <cmd…> # share a specific CLI (skip the picker)
8
-
9
- It spawns the CLI in a PTY so you use it exactly like your normal
10
- terminaland every byte streams to the room over Supabase realtime
11
- (the relay), while prompts/keys from the web come back into the PTY.
12
- No copy/paste. A web page can't read your terminal directly — this
13
- tiny helper is the bridge.
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.3multi-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'
17
22
  import fs from 'node:fs'
18
23
  import path from 'node:path'
19
24
  import readline from 'node:readline'
25
+ import { randomUUID } from 'node:crypto'
20
26
  import { createClient } from '@supabase/supabase-js'
21
27
 
22
28
  // Public client creds (the same anon values the web app ships — safe to embed).
@@ -24,6 +30,10 @@ import { createClient } from '@supabase/supabase-js'
24
30
  const SUPABASE_URL = process.env.TP_SUPABASE_URL || 'https://daytvtakmlixpfbbqzjd.supabase.co'
25
31
  const SUPABASE_ANON = process.env.TP_SUPABASE_ANON || 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImRheXR2dGFrbWxpeHBmYmJxempkIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzgwNTMxNzEsImV4cCI6MjA5MzYyOTE3MX0.zYmz8bHUNEY4STUr2JuXChAsKwxNwkwFHe1Hd0Betqo'
26
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
+
27
37
  // node-pty is a native module — loaded lazily so a friendly message shows
28
38
  // if it isn't built yet.
29
39
  let pty
@@ -82,24 +92,32 @@ const pickAgent = (installed) => new Promise((resolve) => {
82
92
 
83
93
  const argv = process.argv.slice(2)
84
94
  const room = (argv[0] || '').toUpperCase().trim()
85
- 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))
86
98
  const dashIdx = argv.indexOf('--')
87
- let cmd, cmdArgs
99
+ let attachedCmd = null, attachedArgs = []
88
100
  if (dashIdx >= 0) {
89
- // Explicit override: `-- <cmd> [args…]` skips the picker.
90
- ;[cmd, ...cmdArgs] = argv.slice(dashIdx + 1)
91
- } else {
92
- // Auto-detect: probe the known agents, then pick (or auto-pick a lone match).
93
- const installed = KNOWN_AGENTS.filter(a => onPath(a.cmd))
94
- if (installed.length === 0) {
101
+ ;[attachedCmd, ...attachedArgs] = argv.slice(dashIdx + 1)
102
+ } else if (!headless) {
103
+ if (installedAgents.length === 0) {
95
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`)
96
105
  process.exit(1)
97
106
  }
98
- cmd = await pickAgent(installed)
99
- cmdArgs = []
107
+ attachedCmd = await pickAgent(installedAgents)
100
108
  }
101
109
  const name = process.env.TP_NAME || os.userInfo().username || 'host'
102
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
+
103
121
  const supabase = createClient(SUPABASE_URL, SUPABASE_ANON, {
104
122
  realtime: { params: { eventsPerSecond: 60 } },
105
123
  })
@@ -107,52 +125,163 @@ const channel = supabase.channel(`tpcode:${room}`, {
107
125
  config: { broadcast: { self: false }, presence: { key: `bridge:${name}` } },
108
126
  })
109
127
 
110
- // ── spawn the CLI in a PTY; the host uses it like a normal terminal ──
111
- const cols = process.stdout.columns || 100
112
- const rows = process.stdout.rows || 30
113
- const term = pty.spawn(cmd || 'claude', cmdArgs, {
114
- name: 'xterm-256color', cols, rows, cwd: process.cwd(), env: process.env,
128
+ // ── terminal registry ──────────────────────────────────────────────
129
+ // id { term (pty), cmd, attached, scrollback, buf }
130
+ const terms = new Map()
131
+ let attachedId = null
132
+ let shuttingDown = false
133
+
134
+ const announce = () =>
135
+ channel.send({ type: 'broadcast', event: 'bridge', payload: {
136
+ v: 2, name, repo: repoLabel, branch,
137
+ agents: installedAgents,
138
+ // cols/rows: the PTY's one true size — web viewers render this grid and
139
+ // scale it to their own page instead of voting to reflow it.
140
+ terms: [...terms.entries()].map(([id, t]) => ({ id, cmd: t.cmd, alive: true, cols: t.term.cols, rows: t.term.rows })),
141
+ } })
142
+
143
+ // One flush timer batches every terminal's pending bytes (~35ms cadence).
144
+ const flushAll = () => {
145
+ for (const [id, t] of terms) {
146
+ if (!t.buf) continue
147
+ const b64 = Buffer.from(t.buf, 'utf8').toString('base64')
148
+ t.buf = ''
149
+ channel.send({ type: 'broadcast', event: 'pty-out', payload: { term: id, b64 } })
150
+ }
151
+ }
152
+ const flushTimer = setInterval(flushAll, 35)
153
+
154
+ // ── shared-PTY geometry ───────────────────────────────────────────
155
+ // The room's view is the product: the shared PTY never goes below
156
+ // FLOOR_COLS×FLOOR_ROWS, no matter what the host does to their local
157
+ // window. Below the floor the HOST's rendering wraps oddly (the agent
158
+ // keeps drawing for the floor width) — the pair's view stays clean.
159
+ // Pin an exact size with TP_COLS/TP_ROWS (disables host-following).
160
+ const FLOOR_COLS = 100, FLOOR_ROWS = 28
161
+ const PIN_COLS = parseInt(process.env.TP_COLS, 10) || null
162
+ const PIN_ROWS = parseInt(process.env.TP_ROWS, 10) || null
163
+ const attachedDims = () => ({
164
+ cols: PIN_COLS || Math.max(process.stdout.columns || FLOOR_COLS, FLOOR_COLS),
165
+ rows: PIN_ROWS || Math.max(process.stdout.rows || FLOOR_ROWS, FLOOR_ROWS),
115
166
  })
116
167
 
117
- // host's own keystrokes the CLI (so it's seamless locally too)
118
- if (process.stdin.isTTY) process.stdin.setRawMode(true)
119
- process.stdin.resume()
120
- process.stdin.on('data', d => term.write(d.toString('utf8')))
121
-
122
- // CLI output host's screen + batched out to the room
123
- let buf = ''
124
- term.onData(d => { process.stdout.write(d); buf += d })
125
- const flush = () => {
126
- if (!buf) return
127
- const b64 = Buffer.from(buf, 'utf8').toString('base64'); buf = ''
128
- channel.send({ type: 'broadcast', event: 'pty-out', payload: { b64 } })
168
+ function openTerm({ id, cmd, args = [], attached = false, cols, rows }) {
169
+ if (terms.has(id)) return
170
+ const ad = attached ? attachedDims() : null
171
+ const term = pty.spawn(cmd, args, {
172
+ name: 'xterm-256color',
173
+ cols: cols || (attached ? ad.cols : 100),
174
+ rows: rows || (attached ? ad.rows : 30),
175
+ cwd: process.cwd(), env: process.env,
176
+ })
177
+ const entry = { term, cmd, attached, scrollback: '', buf: '' }
178
+ terms.set(id, entry)
179
+ if (attached) attachedId = id
180
+ term.onData(d => {
181
+ if (attached) process.stdout.write(d)
182
+ entry.buf += d
183
+ entry.scrollback = (entry.scrollback + d).slice(-SCROLLBACK_MAX)
184
+ })
185
+ term.onExit(() => {
186
+ terms.delete(id)
187
+ if (id === attachedId) { attachedId = null; detachLocal() }
188
+ if (shuttingDown) return
189
+ try { channel.send({ type: 'broadcast', event: 'term-exit', payload: { id } }) } catch { /* noop */ }
190
+ announce()
191
+ if (terms.size === 0 && !headless) shutdown()
192
+ else process.stderr.write(`\n ◇ terminal "${cmd}" ended — still relaying ${terms.size} terminal(s). Ctrl-C to stop.\n`)
193
+ })
194
+ announce()
195
+ return entry
129
196
  }
130
- const flushTimer = setInterval(flush, 35)
131
197
 
132
- const announce = () =>
133
- channel.send({ type: 'broadcast', event: 'bridge', payload: { name, cmd: cmd || 'claude', cols, rows } })
198
+ // After the attached CLI exits, the host's stdin stops feeding a PTY —
199
+ // restore the cooked terminal so Ctrl-C reaches the bridge itself.
200
+ const detachLocal = () => {
201
+ try { if (process.stdin.isTTY) process.stdin.setRawMode(false) } catch { /* noop */ }
202
+ }
203
+
204
+ // host's own keystrokes → the attached CLI (so it's seamless locally too)
205
+ if (attachedCmd && process.stdin.isTTY) process.stdin.setRawMode(true)
206
+ process.stdin.resume()
207
+ process.stdin.on('data', d => {
208
+ const t = attachedId && terms.get(attachedId)
209
+ if (t) t.term.write(d.toString('utf8'))
210
+ else if (d.includes(3)) shutdown() // Ctrl-C once detached/headless
211
+ })
212
+ // attached terminal follows the host's TTY size — but never below the
213
+ // floor, and never at all when pinned. Re-announce so viewers rescale.
214
+ process.stdout.on('resize', () => {
215
+ const t = attachedId && terms.get(attachedId)
216
+ if (!t) return
217
+ const { cols, rows } = attachedDims()
218
+ if (t.term.cols === cols && t.term.rows === rows) return
219
+ try { t.term.resize(cols, rows); announce() } catch { /* noop */ }
220
+ })
134
221
 
135
222
  channel
136
- .on('broadcast', { event: 'pty-in' }, ({ payload }) => { if (payload?.data) term.write(payload.data) })
223
+ .on('broadcast', { event: 'pty-in' }, ({ payload }) => {
224
+ if (!payload?.data) return
225
+ const t = terms.get(payload.term) || (payload.term == null && attachedId ? terms.get(attachedId) : null)
226
+ if (t) t.term.write(payload.data)
227
+ })
137
228
  .on('broadcast', { event: 'resize' }, ({ payload }) => {
138
- if (payload?.cols && payload?.rows) { try { term.resize(payload.cols, payload.rows) } catch { /* noop */ } }
229
+ // headless terms are web-sized (last writer wins); the attached term is
230
+ // host-sized. Floor at 80 cols — one narrow phone viewer must not reflow
231
+ // the shared PTY into a 40-col column for everyone (2026-06-10 bug).
232
+ if (!payload?.cols || !payload?.rows) return
233
+ const t = terms.get(payload.term)
234
+ if (t && !t.attached) { try { t.term.resize(Math.max(payload.cols, 80), Math.max(payload.rows, 20)); announce() } catch { /* noop */ } }
235
+ })
236
+ .on('broadcast', { event: 'term-open' }, ({ payload }) => {
237
+ if (!payload?.id || !payload?.cmd) return
238
+ // Multi-bridge rooms: a targeted open is for ONE machine. Untargeted
239
+ // opens (older web) are taken by whoever hears them — the solo case.
240
+ if (payload.host && payload.host !== name) return
241
+ openTerm({ id: payload.id, cmd: payload.cmd })
242
+ process.stderr.write(`\n ◆ web opened a "${payload.cmd}" terminal (headless).\n`)
243
+ })
244
+ .on('broadcast', { event: 'term-close' }, ({ payload }) => {
245
+ const t = payload?.id && terms.get(payload.id)
246
+ if (t && !t.attached) { try { t.term.kill() } catch { /* noop */ } }
247
+ })
248
+ .on('broadcast', { event: 'replay-request' }, ({ payload }) => {
249
+ // Flush pending bytes first so the replay is complete up to "now"; both
250
+ // ride the same ordered socket, so the client sees replay-then-live.
251
+ flushAll()
252
+ for (const [id, t] of terms) {
253
+ if (!t.scrollback) continue
254
+ channel.send({ type: 'broadcast', event: 'pty-replay', payload: {
255
+ to: payload?.to ?? null, term: id,
256
+ b64: Buffer.from(t.scrollback, 'utf8').toString('base64'),
257
+ } })
258
+ }
259
+ announce()
139
260
  })
140
261
  .on('broadcast', { event: 'who' }, announce)
141
262
  .subscribe(status => {
142
263
  if (status === 'SUBSCRIBED') {
143
264
  channel.track({ name, role: 'bridge' })
265
+ if (attachedCmd && !terms.size) openTerm({ id: randomUUID(), cmd: attachedCmd, args: attachedArgs, attached: true })
144
266
  announce()
145
- process.stderr.write(`\n ◆ thinkpool — sharing "${cmd || 'claude'}" into room ${room}. Open the web UI and you're both in.\n\n`)
267
+ process.stderr.write(headless
268
+ ? `\n ◆ thinkpool — relaying room ${room} (headless). Open terminals from the web UI.\n\n`
269
+ : `\n ◆ thinkpool — sharing "${attachedCmd}" into room ${room}. Open the web UI and you're both in.\n\n`)
146
270
  }
147
271
  })
148
272
 
149
- const shutdown = () => {
273
+ function shutdown() {
274
+ if (shuttingDown) return
275
+ shuttingDown = true
150
276
  clearInterval(flushTimer)
151
- try { channel.send({ type: 'broadcast', event: 'pty-out', payload: { b64: Buffer.from('\r\n[ shared session ended ]\r\n', 'utf8').toString('base64') } }) } catch { /* noop */ }
277
+ try {
278
+ const bye = Buffer.from('\r\n[ shared session ended ]\r\n', 'utf8').toString('base64')
279
+ for (const id of terms.keys()) channel.send({ type: 'broadcast', event: 'pty-out', payload: { term: id, b64: bye } })
280
+ } catch { /* noop */ }
152
281
  try { supabase.removeChannel(channel) } catch { /* noop */ }
153
- try { term.kill() } catch { /* noop */ }
282
+ for (const t of terms.values()) { try { t.term.kill() } catch { /* noop */ } }
283
+ detachLocal()
154
284
  process.exit(0)
155
285
  }
156
- term.onExit(shutdown)
157
286
  process.on('SIGINT', shutdown)
158
287
  process.on('SIGTERM', shutdown)
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.2.0",
3
+ "version": "0.3.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
- "bin": "bridge.mjs",
6
+ "bin": { "thinkpool-pair": "bridge.mjs" },
7
7
  "engines": {
8
8
  "node": ">=18"
9
9
  },