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.
- package/bridge.mjs +59 -7
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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())
|
|
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