thinkpool-pair 0.3.6 → 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 +59 -7
  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,7 +178,7 @@ 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)
@@ -194,7 +226,7 @@ function openTerm({ id, cmd, args = [], attached = false, cols, rows }) {
194
226
  terms.delete(id)
195
227
  if (id === attachedId) { attachedId = null; detachLocal() }
196
228
  if (shuttingDown) return
197
- try { channel.send({ type: 'broadcast', event: 'term-exit', payload: { id } }) } catch { /* noop */ }
229
+ bcast('term-exit', { id })
198
230
  announce()
199
231
  if (terms.size === 0 && !headless) shutdown()
200
232
  else process.stderr.write(`\n ◇ terminal "${cmd}" ended — still relaying ${terms.size} terminal(s). Ctrl-C to stop.\n`)
@@ -259,13 +291,33 @@ channel
259
291
  flushAll()
260
292
  for (const [id, t] of terms) {
261
293
  if (!t.scrollback) continue
262
- channel.send({ type: 'broadcast', event: 'pty-replay', payload: {
294
+ bcast('pty-replay', {
263
295
  to: payload?.to ?? null, term: id,
264
296
  b64: Buffer.from(t.scrollback, 'utf8').toString('base64'),
265
- } })
297
+ })
266
298
  }
267
299
  announce()
268
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
+ })
269
321
  .on('broadcast', { event: 'who' }, announce)
270
322
  .subscribe(status => {
271
323
  if (status === 'SUBSCRIBED') {
@@ -284,7 +336,7 @@ function shutdown() {
284
336
  clearInterval(flushTimer)
285
337
  try {
286
338
  const bye = Buffer.from('\r\n[ shared session ended ]\r\n', 'utf8').toString('base64')
287
- 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 })
288
340
  } catch { /* noop */ }
289
341
  try { supabase.removeChannel(channel) } catch { /* noop */ }
290
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.6",
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" },