thinkpool-pair 0.3.5 → 0.4.0

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 (2) hide show
  1. package/bridge.mjs +76 -21
  2. package/package.json +1 -1
package/bridge.mjs CHANGED
@@ -7,6 +7,8 @@
7
7
  npx thinkpool-pair <ROOM> -- <cmd…> # share a specific CLI (skip the picker)
8
8
  npx thinkpool-pair <ROOM> --headless # pure relay — terminals open from the web
9
9
 
10
+ v0.4 — room file drops: files dropped/pasted in the web room download
11
+ to a temp dir here (file-put → file-done) so the agent can read them.
10
12
  v0.3 — multi-terminal. The bridge is your machine's presence in the
11
13
  room; terminals are things you open inside it. Your first agent runs
12
14
  ATTACHED (your own terminal, exactly as before). The web's
@@ -118,6 +120,17 @@ try {
118
120
  branch = head.startsWith('ref:') ? head.split('/').pop() : head.slice(0, 7)
119
121
  } catch { /* not a git repo — fine */ }
120
122
 
123
+ // ── room file drops ────────────────────────────────────────────────
124
+ // Files dropped/pasted in the web room arrive as `file-put { id, name, url }`
125
+ // (a 1h signed Storage URL) — download to a per-room temp dir and answer
126
+ // `file-done { id, path }`. The filename rule <id8>-<safe> is DETERMINISTIC
127
+ // and shared with the web client, which computes host paths from the
128
+ // announced `updir` without waiting on this round-trip. Spec:
129
+ // docs/specs/2026-06-11-code-file-drop.md
130
+ const UPDIR = path.join(os.tmpdir(), 'thinkpool-pair', room)
131
+ const FILE_MAX_BYTES = 10 * 1024 * 1024
132
+ const safeName = (n) => String(n || 'file').replace(/[^a-zA-Z0-9._-]/g, '_').slice(-80)
133
+
121
134
  const supabase = createClient(SUPABASE_URL, SUPABASE_ANON, {
122
135
  realtime: { params: { eventsPerSecond: 60 } },
123
136
  })
@@ -131,14 +144,33 @@ const terms = new Map()
131
144
  let attachedId = null
132
145
  let shuttingDown = false
133
146
 
147
+ // One send door for every broadcast. supabase-js ≥2.10x deprecation-warns
148
+ // (console.warn) whenever send() silently falls back to REST because the
149
+ // socket can't push — at the flush timer's 28×/s that's a wall of warnings
150
+ // straight into the host's ATTACHED terminal, shredding the TUI
151
+ // (2026-06-11). Same delivery, explicit path, no warning: httpSend when
152
+ // the socket is down. Loss on total offline is fine — scrollback replays.
153
+ const bcast = (event, payload) => {
154
+ try {
155
+ if (channel.channelAdapter?.canPush?.() ?? true) {
156
+ channel.send({ type: 'broadcast', event, payload })
157
+ } else {
158
+ channel.httpSend(event, payload).catch(() => { /* offline — replay covers it */ })
159
+ }
160
+ } catch { /* noop */ }
161
+ }
162
+
134
163
  const announce = () =>
135
- channel.send({ type: 'broadcast', event: 'bridge', payload: {
164
+ bcast('bridge', {
136
165
  v: 2, name, repo: repoLabel, branch,
166
+ // updir: where room file-drops land (forward-slash normalised — the web
167
+ // client string-joins host paths onto it; Node accepts `/` on Windows).
168
+ updir: UPDIR.split(path.sep).join('/'),
137
169
  agents: installedAgents,
138
170
  // cols/rows: the PTY's one true size — web viewers render this grid and
139
171
  // scale it to their own page instead of voting to reflow it.
140
172
  terms: [...terms.entries()].map(([id, t]) => ({ id, cmd: t.cmd, alive: true, cols: t.term.cols, rows: t.term.rows })),
141
- } })
173
+ })
142
174
 
143
175
  // One flush timer batches every terminal's pending bytes (~35ms cadence).
144
176
  const flushAll = () => {
@@ -146,28 +178,31 @@ const flushAll = () => {
146
178
  if (!t.buf) continue
147
179
  const b64 = Buffer.from(t.buf, 'utf8').toString('base64')
148
180
  t.buf = ''
149
- channel.send({ type: 'broadcast', event: 'pty-out', payload: { term: id, b64 } })
181
+ bcast('pty-out', { term: id, b64 })
150
182
  }
151
183
  }
152
184
  const flushTimer = setInterval(flushAll, 35)
153
185
 
154
186
  // ── shared-PTY geometry ───────────────────────────────────────────
155
- // The room's view is the product. DEFAULT: the shared PTY is PINNED to a
156
- // standard 120×32 one source resolution chosen for web legibility
157
- // (scale ≈1 on a laptop pane), regardless of the host's window. A huge
158
- // host window made viewers' text microscopic; a tiny one broke their
159
- // layoutboth ends of the same bug (2026-06-10). The host's local
160
- // terminal just doesn't use its extra space; below 120×32 it wraps
161
- // oddly locally, the room stays clean.
162
- // TP_COLS/TP_ROWS pin a different size. TP_FOLLOW=1 restores
163
- // follow-the-host-window (floored), for solo screen-mirror use.
164
- const FLOOR_COLS = 100, FLOOR_ROWS = 28
187
+ // The attached PTY is CAPPED at 120×32, never pinned (2026-06-11).
188
+ // Mangling physics: a PTY *wider/taller* than the host's window forces
189
+ // mid-line wraps the host's own view shreds (the 2026-06-10 pin did
190
+ // exactly this to any window under 120 cols). A PTY *smaller* than the
191
+ // window is harmless it just leaves an empty margin. So per axis:
192
+ // attached = min(host TTY, 120×32)
193
+ // Host view is always clean (PTY ≤ window); web viewers never get
194
+ // microscopic text (grid 120 cols — the original reason for the pin;
195
+ // they render the exact grid and scale, per d78c978).
196
+ // TP_COLS/TP_ROWS force an exact size (you own the consequences);
197
+ // TP_FOLLOW=1 follows the host window uncapped (solo screen-mirror).
198
+ const CAP_COLS = 120, CAP_ROWS = 32
199
+ const FALLBACK_COLS = 100, FALLBACK_ROWS = 28 // stdout size unknown (rare)
165
200
  const FOLLOW = process.env.TP_FOLLOW === '1'
166
- const PIN_COLS = parseInt(process.env.TP_COLS, 10) || (FOLLOW ? null : 120)
167
- const PIN_ROWS = parseInt(process.env.TP_ROWS, 10) || (FOLLOW ? null : 32)
201
+ const PIN_COLS = parseInt(process.env.TP_COLS, 10) || null
202
+ const PIN_ROWS = parseInt(process.env.TP_ROWS, 10) || null
168
203
  const attachedDims = () => ({
169
- cols: PIN_COLS || Math.max(process.stdout.columns || FLOOR_COLS, FLOOR_COLS),
170
- rows: PIN_ROWS || Math.max(process.stdout.rows || FLOOR_ROWS, FLOOR_ROWS),
204
+ cols: PIN_COLS || (FOLLOW ? (process.stdout.columns || FALLBACK_COLS) : Math.min(process.stdout.columns || FALLBACK_COLS, CAP_COLS)),
205
+ rows: PIN_ROWS || (FOLLOW ? (process.stdout.rows || FALLBACK_ROWS) : Math.min(process.stdout.rows || FALLBACK_ROWS, CAP_ROWS)),
171
206
  })
172
207
 
173
208
  function openTerm({ id, cmd, args = [], attached = false, cols, rows }) {
@@ -191,7 +226,7 @@ function openTerm({ id, cmd, args = [], attached = false, cols, rows }) {
191
226
  terms.delete(id)
192
227
  if (id === attachedId) { attachedId = null; detachLocal() }
193
228
  if (shuttingDown) return
194
- try { channel.send({ type: 'broadcast', event: 'term-exit', payload: { id } }) } catch { /* noop */ }
229
+ bcast('term-exit', { id })
195
230
  announce()
196
231
  if (terms.size === 0 && !headless) shutdown()
197
232
  else process.stderr.write(`\n ◇ terminal "${cmd}" ended — still relaying ${terms.size} terminal(s). Ctrl-C to stop.\n`)
@@ -256,13 +291,33 @@ channel
256
291
  flushAll()
257
292
  for (const [id, t] of terms) {
258
293
  if (!t.scrollback) continue
259
- channel.send({ type: 'broadcast', event: 'pty-replay', payload: {
294
+ bcast('pty-replay', {
260
295
  to: payload?.to ?? null, term: id,
261
296
  b64: Buffer.from(t.scrollback, 'utf8').toString('base64'),
262
- } })
297
+ })
263
298
  }
264
299
  announce()
265
300
  })
301
+ .on('broadcast', { event: 'file-put' }, ({ payload }) => {
302
+ if (!payload?.id || !payload?.url) return
303
+ // Multi-bridge rooms: targeted like term-open; untargeted = solo case.
304
+ if (payload.host && payload.host !== name) return
305
+ ;(async () => {
306
+ try {
307
+ const r = await fetch(payload.url)
308
+ if (!r.ok) throw new Error(`HTTP ${r.status}`)
309
+ const buf = Buffer.from(await r.arrayBuffer())
310
+ if (buf.length > FILE_MAX_BYTES) throw new Error('file too large')
311
+ fs.mkdirSync(UPDIR, { recursive: true })
312
+ const fp = path.join(UPDIR, `${String(payload.id).slice(0, 8)}-${safeName(payload.name)}`)
313
+ fs.writeFileSync(fp, buf)
314
+ process.stderr.write(`\n ◆ file from the room: ${fp}\n`)
315
+ bcast('file-done', { id: payload.id, path: fp.split(path.sep).join('/'), host: name })
316
+ } catch (e) {
317
+ process.stderr.write(`\n ◇ room file download failed (${safeName(payload.name) || payload.id}): ${e.message}\n`)
318
+ }
319
+ })()
320
+ })
266
321
  .on('broadcast', { event: 'who' }, announce)
267
322
  .subscribe(status => {
268
323
  if (status === 'SUBSCRIBED') {
@@ -281,7 +336,7 @@ function shutdown() {
281
336
  clearInterval(flushTimer)
282
337
  try {
283
338
  const bye = Buffer.from('\r\n[ shared session ended ]\r\n', 'utf8').toString('base64')
284
- for (const id of terms.keys()) channel.send({ type: 'broadcast', event: 'pty-out', payload: { term: id, b64: bye } })
339
+ for (const id of terms.keys()) bcast('pty-out', { term: id, b64: bye })
285
340
  } catch { /* noop */ }
286
341
  try { supabase.removeChannel(channel) } catch { /* noop */ }
287
342
  for (const t of terms.values()) { try { t.term.kill() } catch { /* noop */ } }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.3.5",
3
+ "version": "0.4.0",
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": { "thinkpool-pair": "bridge.mjs" },