thinkpool-pair 0.3.6 → 0.5.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 +109 -11
- package/package.json +1 -1
package/bridge.mjs
CHANGED
|
@@ -7,6 +7,16 @@
|
|
|
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.5 — continue, don't restart: when the picked agent supports
|
|
11
|
+
resuming (Claude Code: `--continue` = most recent conversation in
|
|
12
|
+
THIS directory) and a prior session exists here, the bridge offers to
|
|
13
|
+
continue it instead of starting fresh — quit your running agent, run
|
|
14
|
+
the bridge in the same directory, and you're back in the same
|
|
15
|
+
conversation, now shared. `--continue` / `--fresh` skip the question.
|
|
16
|
+
(True attach to a live process was ruled out: reptyr-style TTY
|
|
17
|
+
hijacking is Linux-only and SIP-blocked on macOS.)
|
|
18
|
+
v0.4 — room file drops: files dropped/pasted in the web room download
|
|
19
|
+
to a temp dir here (file-put → file-done) so the agent can read them.
|
|
10
20
|
v0.3 — multi-terminal. The bridge is your machine's presence in the
|
|
11
21
|
room; terminals are things you open inside it. Your first agent runs
|
|
12
22
|
ATTACHED (your own terminal, exactly as before). The web's
|
|
@@ -47,8 +57,12 @@ catch {
|
|
|
47
57
|
// Order = display order; Claude Code stays first (the original default). The
|
|
48
58
|
// picker only offers the ones actually installed (PATH probe below). Power
|
|
49
59
|
// users can bypass it entirely with `-- <any command>`.
|
|
60
|
+
// `resume` marks agents where continuing the latest session is a SAFE,
|
|
61
|
+
// verified flag (wrong flags kill the spawn → bridge exits — only add
|
|
62
|
+
// agents after checking the real CLI). probe() must be cheap + read-only;
|
|
63
|
+
// a miss just means "continue" isn't offered.
|
|
50
64
|
const KNOWN_AGENTS = [
|
|
51
|
-
{ label: 'Claude Code', cmd: 'claude' },
|
|
65
|
+
{ label: 'Claude Code', cmd: 'claude', resume: { args: ['--continue'], probe: claudeHasSession } },
|
|
52
66
|
{ label: 'Codex CLI', cmd: 'codex' },
|
|
53
67
|
{ label: 'Gemini CLI', cmd: 'gemini' },
|
|
54
68
|
{ label: 'Aider', cmd: 'aider' },
|
|
@@ -60,6 +74,16 @@ const KNOWN_AGENTS = [
|
|
|
60
74
|
{ label: 'Qwen Code', cmd: 'qwen' },
|
|
61
75
|
]
|
|
62
76
|
|
|
77
|
+
// Does Claude Code have a resumable conversation for THIS directory?
|
|
78
|
+
// Sessions live under ~/.claude/projects/<cwd with [/\.:] → '-'> (one
|
|
79
|
+
// subdir per session). Read-only probe; any miss = no "continue" offer.
|
|
80
|
+
function claudeHasSession() {
|
|
81
|
+
try {
|
|
82
|
+
const slug = path.resolve(process.cwd()).replace(/[/\\.:]/g, '-')
|
|
83
|
+
return fs.readdirSync(path.join(os.homedir(), '.claude', 'projects', slug)).length > 0
|
|
84
|
+
} catch { return false }
|
|
85
|
+
}
|
|
86
|
+
|
|
63
87
|
// Is `c` on PATH? Pure-Node scan (no `which` subprocess), cross-platform.
|
|
64
88
|
const onPath = (c) => {
|
|
65
89
|
const exts = process.platform === 'win32'
|
|
@@ -92,11 +116,23 @@ const pickAgent = (installed) => new Promise((resolve) => {
|
|
|
92
116
|
|
|
93
117
|
const argv = process.argv.slice(2)
|
|
94
118
|
const room = (argv[0] || '').toUpperCase().trim()
|
|
95
|
-
if (!room || room.startsWith('-')) { console.error('usage: npx thinkpool-pair <ROOM> [--headless] [-- <command…>]'); process.exit(1) }
|
|
119
|
+
if (!room || room.startsWith('-')) { console.error('usage: npx thinkpool-pair <ROOM> [--headless] [--continue|--fresh] [-- <command…>]'); process.exit(1) }
|
|
96
120
|
const headless = argv.includes('--headless')
|
|
97
121
|
const installedAgents = KNOWN_AGENTS.filter(a => onPath(a.cmd))
|
|
98
122
|
const dashIdx = argv.indexOf('--')
|
|
99
|
-
|
|
123
|
+
// Own flags are only read from BEFORE `--`; after it, every token belongs
|
|
124
|
+
// to the shared command (`-- claude --continue` must not trip these).
|
|
125
|
+
const ownArgs = dashIdx >= 0 ? argv.slice(0, dashIdx) : argv
|
|
126
|
+
const forceContinue = ownArgs.includes('--continue')
|
|
127
|
+
const forceFresh = ownArgs.includes('--fresh')
|
|
128
|
+
|
|
129
|
+
// One yes/no on the bridge's stderr (same channel as the agent picker).
|
|
130
|
+
const askYesNo = (q) => new Promise((resolve) => {
|
|
131
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stderr })
|
|
132
|
+
rl.question(q, (ans) => { rl.close(); resolve(!/^n/i.test(String(ans).trim())) })
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
let attachedCmd = null, attachedArgs = [], continuing = false
|
|
100
136
|
if (dashIdx >= 0) {
|
|
101
137
|
;[attachedCmd, ...attachedArgs] = argv.slice(dashIdx + 1)
|
|
102
138
|
} else if (!headless) {
|
|
@@ -105,6 +141,18 @@ if (dashIdx >= 0) {
|
|
|
105
141
|
process.exit(1)
|
|
106
142
|
}
|
|
107
143
|
attachedCmd = await pickAgent(installedAgents)
|
|
144
|
+
// Continue the latest session instead of starting fresh — offered only
|
|
145
|
+
// when the agent supports it AND a prior session exists in this cwd.
|
|
146
|
+
// --continue forces it (your call even if the probe misses); --fresh
|
|
147
|
+
// skips the question; non-TTY stays fresh (no surprise in scripts).
|
|
148
|
+
const agent = KNOWN_AGENTS.find(a => a.cmd === attachedCmd)
|
|
149
|
+
if (agent?.resume && !forceFresh) {
|
|
150
|
+
if (forceContinue) continuing = true
|
|
151
|
+
else if (process.stdin.isTTY && agent.resume.probe()) {
|
|
152
|
+
continuing = await askYesNo(`\n Continue your latest ${agent.label} session in this directory? [Y/n] `)
|
|
153
|
+
}
|
|
154
|
+
if (continuing) attachedArgs = [...agent.resume.args]
|
|
155
|
+
}
|
|
108
156
|
}
|
|
109
157
|
const name = process.env.TP_NAME || os.userInfo().username || 'host'
|
|
110
158
|
|
|
@@ -118,6 +166,17 @@ try {
|
|
|
118
166
|
branch = head.startsWith('ref:') ? head.split('/').pop() : head.slice(0, 7)
|
|
119
167
|
} catch { /* not a git repo — fine */ }
|
|
120
168
|
|
|
169
|
+
// ── room file drops ────────────────────────────────────────────────
|
|
170
|
+
// Files dropped/pasted in the web room arrive as `file-put { id, name, url }`
|
|
171
|
+
// (a 1h signed Storage URL) — download to a per-room temp dir and answer
|
|
172
|
+
// `file-done { id, path }`. The filename rule <id8>-<safe> is DETERMINISTIC
|
|
173
|
+
// and shared with the web client, which computes host paths from the
|
|
174
|
+
// announced `updir` without waiting on this round-trip. Spec:
|
|
175
|
+
// docs/specs/2026-06-11-code-file-drop.md
|
|
176
|
+
const UPDIR = path.join(os.tmpdir(), 'thinkpool-pair', room)
|
|
177
|
+
const FILE_MAX_BYTES = 10 * 1024 * 1024
|
|
178
|
+
const safeName = (n) => String(n || 'file').replace(/[^a-zA-Z0-9._-]/g, '_').slice(-80)
|
|
179
|
+
|
|
121
180
|
const supabase = createClient(SUPABASE_URL, SUPABASE_ANON, {
|
|
122
181
|
realtime: { params: { eventsPerSecond: 60 } },
|
|
123
182
|
})
|
|
@@ -131,14 +190,33 @@ const terms = new Map()
|
|
|
131
190
|
let attachedId = null
|
|
132
191
|
let shuttingDown = false
|
|
133
192
|
|
|
193
|
+
// One send door for every broadcast. supabase-js ≥2.10x deprecation-warns
|
|
194
|
+
// (console.warn) whenever send() silently falls back to REST because the
|
|
195
|
+
// socket can't push — at the flush timer's 28×/s that's a wall of warnings
|
|
196
|
+
// straight into the host's ATTACHED terminal, shredding the TUI
|
|
197
|
+
// (2026-06-11). Same delivery, explicit path, no warning: httpSend when
|
|
198
|
+
// the socket is down. Loss on total offline is fine — scrollback replays.
|
|
199
|
+
const bcast = (event, payload) => {
|
|
200
|
+
try {
|
|
201
|
+
if (channel.channelAdapter?.canPush?.() ?? true) {
|
|
202
|
+
channel.send({ type: 'broadcast', event, payload })
|
|
203
|
+
} else {
|
|
204
|
+
channel.httpSend(event, payload).catch(() => { /* offline — replay covers it */ })
|
|
205
|
+
}
|
|
206
|
+
} catch { /* noop */ }
|
|
207
|
+
}
|
|
208
|
+
|
|
134
209
|
const announce = () =>
|
|
135
|
-
|
|
210
|
+
bcast('bridge', {
|
|
136
211
|
v: 2, name, repo: repoLabel, branch,
|
|
212
|
+
// updir: where room file-drops land (forward-slash normalised — the web
|
|
213
|
+
// client string-joins host paths onto it; Node accepts `/` on Windows).
|
|
214
|
+
updir: UPDIR.split(path.sep).join('/'),
|
|
137
215
|
agents: installedAgents,
|
|
138
216
|
// cols/rows: the PTY's one true size — web viewers render this grid and
|
|
139
217
|
// scale it to their own page instead of voting to reflow it.
|
|
140
218
|
terms: [...terms.entries()].map(([id, t]) => ({ id, cmd: t.cmd, alive: true, cols: t.term.cols, rows: t.term.rows })),
|
|
141
|
-
}
|
|
219
|
+
})
|
|
142
220
|
|
|
143
221
|
// One flush timer batches every terminal's pending bytes (~35ms cadence).
|
|
144
222
|
const flushAll = () => {
|
|
@@ -146,7 +224,7 @@ const flushAll = () => {
|
|
|
146
224
|
if (!t.buf) continue
|
|
147
225
|
const b64 = Buffer.from(t.buf, 'utf8').toString('base64')
|
|
148
226
|
t.buf = ''
|
|
149
|
-
|
|
227
|
+
bcast('pty-out', { term: id, b64 })
|
|
150
228
|
}
|
|
151
229
|
}
|
|
152
230
|
const flushTimer = setInterval(flushAll, 35)
|
|
@@ -194,7 +272,7 @@ function openTerm({ id, cmd, args = [], attached = false, cols, rows }) {
|
|
|
194
272
|
terms.delete(id)
|
|
195
273
|
if (id === attachedId) { attachedId = null; detachLocal() }
|
|
196
274
|
if (shuttingDown) return
|
|
197
|
-
|
|
275
|
+
bcast('term-exit', { id })
|
|
198
276
|
announce()
|
|
199
277
|
if (terms.size === 0 && !headless) shutdown()
|
|
200
278
|
else process.stderr.write(`\n ◇ terminal "${cmd}" ended — still relaying ${terms.size} terminal(s). Ctrl-C to stop.\n`)
|
|
@@ -259,13 +337,33 @@ channel
|
|
|
259
337
|
flushAll()
|
|
260
338
|
for (const [id, t] of terms) {
|
|
261
339
|
if (!t.scrollback) continue
|
|
262
|
-
|
|
340
|
+
bcast('pty-replay', {
|
|
263
341
|
to: payload?.to ?? null, term: id,
|
|
264
342
|
b64: Buffer.from(t.scrollback, 'utf8').toString('base64'),
|
|
265
|
-
}
|
|
343
|
+
})
|
|
266
344
|
}
|
|
267
345
|
announce()
|
|
268
346
|
})
|
|
347
|
+
.on('broadcast', { event: 'file-put' }, ({ payload }) => {
|
|
348
|
+
if (!payload?.id || !payload?.url) return
|
|
349
|
+
// Multi-bridge rooms: targeted like term-open; untargeted = solo case.
|
|
350
|
+
if (payload.host && payload.host !== name) return
|
|
351
|
+
;(async () => {
|
|
352
|
+
try {
|
|
353
|
+
const r = await fetch(payload.url)
|
|
354
|
+
if (!r.ok) throw new Error(`HTTP ${r.status}`)
|
|
355
|
+
const buf = Buffer.from(await r.arrayBuffer())
|
|
356
|
+
if (buf.length > FILE_MAX_BYTES) throw new Error('file too large')
|
|
357
|
+
fs.mkdirSync(UPDIR, { recursive: true })
|
|
358
|
+
const fp = path.join(UPDIR, `${String(payload.id).slice(0, 8)}-${safeName(payload.name)}`)
|
|
359
|
+
fs.writeFileSync(fp, buf)
|
|
360
|
+
process.stderr.write(`\n ◆ file from the room: ${fp}\n`)
|
|
361
|
+
bcast('file-done', { id: payload.id, path: fp.split(path.sep).join('/'), host: name })
|
|
362
|
+
} catch (e) {
|
|
363
|
+
process.stderr.write(`\n ◇ room file download failed (${safeName(payload.name) || payload.id}): ${e.message}\n`)
|
|
364
|
+
}
|
|
365
|
+
})()
|
|
366
|
+
})
|
|
269
367
|
.on('broadcast', { event: 'who' }, announce)
|
|
270
368
|
.subscribe(status => {
|
|
271
369
|
if (status === 'SUBSCRIBED') {
|
|
@@ -274,7 +372,7 @@ channel
|
|
|
274
372
|
announce()
|
|
275
373
|
process.stderr.write(headless
|
|
276
374
|
? `\n ◆ thinkpool — relaying room ${room} (headless). Open terminals from the web UI.\n\n`
|
|
277
|
-
: `\n ◆ thinkpool — sharing "${attachedCmd}" into room ${room}. Open the web UI and you're both in.\n\n`)
|
|
375
|
+
: `\n ◆ thinkpool — sharing "${attachedCmd}"${continuing ? ' (continuing your latest session)' : ''} into room ${room}. Open the web UI and you're both in.\n\n`)
|
|
278
376
|
}
|
|
279
377
|
})
|
|
280
378
|
|
|
@@ -284,7 +382,7 @@ function shutdown() {
|
|
|
284
382
|
clearInterval(flushTimer)
|
|
285
383
|
try {
|
|
286
384
|
const bye = Buffer.from('\r\n[ shared session ended ]\r\n', 'utf8').toString('base64')
|
|
287
|
-
for (const id of terms.keys())
|
|
385
|
+
for (const id of terms.keys()) bcast('pty-out', { term: id, b64: bye })
|
|
288
386
|
} catch { /* noop */ }
|
|
289
387
|
try { supabase.removeChannel(channel) } catch { /* noop */ }
|
|
290
388
|
for (const t of terms.values()) { try { t.term.kill() } catch { /* noop */ } }
|
package/package.json
CHANGED