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.
Files changed (2) hide show
  1. package/bridge.mjs +109 -11
  2. 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
- let attachedCmd = null, attachedArgs = []
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
- channel.send({ type: 'broadcast', event: 'bridge', payload: {
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
- channel.send({ type: 'broadcast', event: 'pty-out', payload: { term: id, b64 } })
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
- try { channel.send({ type: 'broadcast', event: 'term-exit', payload: { id } }) } catch { /* noop */ }
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
- channel.send({ type: 'broadcast', event: 'pty-replay', payload: {
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()) channel.send({ type: 'broadcast', event: 'pty-out', payload: { term: id, b64: bye } })
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.3.6",
3
+ "version": "0.5.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" },