thinkpool-pair 0.7.60 → 0.7.61

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/account.mjs CHANGED
@@ -15,6 +15,7 @@ import { createClient } from '@supabase/supabase-js'
15
15
  import os from 'node:os'
16
16
  import { saveAuth, loadAuth, loadDirs, bindDir } from './auth-store.mjs'
17
17
  import { isSafeToRestart } from './update-gate.mjs'
18
+ import { makeThrottledTrack } from './presence.mjs'
18
19
 
19
20
  const VERSION = (() => { try { return JSON.parse(fs.readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version } catch { return null } })()
20
21
 
@@ -259,7 +260,12 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
259
260
  setTimeout(() => { try { releaseSingleton() } catch { /* noop */ } process.exit(1) }, 2500)
260
261
  process.kill(process.pid, 'SIGTERM')
261
262
  })
262
- const pushPresence = () => { try { acct.track({ name: machine, version: VERSION, rooms: [...children.keys()], ts: Date.now() }) } catch { /* noop */ } }
263
+ // Throttled so a realtime reconnect storm can't machine-gun track() into the
264
+ // per-client presence rate limit (ClientPresenceRateLimitReached → channel closed
265
+ // → dashboard reads "no bridge" while sessions run; 2026-06-17/-22/-24). The 15s
266
+ // tick + token-refresh callers are unaffected (their interval exceeds the window).
267
+ const trackPresence = makeThrottledTrack(acct, { minMs: 5000 })
268
+ const pushPresence = () => trackPresence({ name: machine, version: VERSION, rooms: [...children.keys()], ts: Date.now() })
263
269
  // Re-track on EVERY (re)subscribe, not just the first: a realtime reconnect
264
270
  // (network blip, or a token swap mid-flight) rejoins the channel and must
265
271
  // re-announce, or the dashboard would read "no bridge" until the next restart.
package/bridge.mjs CHANGED
@@ -40,6 +40,7 @@ import { startClaudeSession } from './claude-session.mjs'
40
40
  import { turnInFlight } from './update-gate.mjs'
41
41
  import { saveSession, flushSession, deleteSession, loadAll, canResume, loadPtyId, savePtyId, loadNames, saveNames } from './session-store.mjs'
42
42
  import { stampEvent, makeSeqCounter, maxSeq, seqable } from './event-id.mjs'
43
+ import { makeThrottledTrack } from './presence.mjs'
43
44
 
44
45
  // Public client creds (the same anon values the web app ships — safe to embed).
45
46
  // Override with TP_SUPABASE_URL / TP_SUPABASE_ANON if you ever need to.
@@ -476,6 +477,13 @@ const safeName = (n) => String(n || 'file').replace(/[^a-zA-Z0-9._-]/g, '_').sli
476
477
  const supabase = createClient(SUPABASE_URL, SUPABASE_ANON, {
477
478
  realtime: { params: { eventsPerSecond: 60 } },
478
479
  })
480
+ // Throttled presence track() (presence.mjs) — a reconnect storm re-fires SUBSCRIBED and
481
+ // the handler re-track()s; without throttle that spirals into the per-client presence
482
+ // rate limit (ClientPresenceRateLimitReached). Lazy so it binds `channel` (the chain
483
+ // result below) only on first call — always after the async subscribe callback, by which
484
+ // point the const is assigned.
485
+ let _presence = null
486
+ const trackPresence = (payload) => { (_presence ||= makeThrottledTrack(channel, { minMs: 5000 }))(payload) }
479
487
  const channel = supabase.channel(`tpcode:${room}`, {
480
488
  config: { broadcast: { self: false }, presence: { key: `bridge:${name}` } },
481
489
  })
@@ -747,7 +755,7 @@ function printLocal(evt) {
747
755
  case 'you': return w(`${A.cyan}❯ ${evt.text}${A.rst}${evt.by ? ` ${A.dim}(${evt.by})${A.rst}` : ''}`)
748
756
  case 'chat': return w(`${A.dim}${evt.by}: ${evt.text}${A.rst}`)
749
757
  case 'whisper': return w(`${A.dim}// ${evt.by}: ${evt.text}${A.rst}`)
750
- case 'pool': return w(evt.pending ? `${A.cyan}◌ pool synthesizing…${A.rst}` : `${A.cyan}◆ pool → ${evt.text}${A.rst}`)
758
+ case 'pool': return w(evt.pending ? `${A.cyan}◌ pool dispatching…${A.rst}` : `${A.cyan}◆ pool → ${evt.text}${A.rst}`)
751
759
  case 'note': return w(`${A.dim}— ${evt.text} —${A.rst}`)
752
760
  case 'clear': return w(`${A.dim}— context cleared —${A.rst}`)
753
761
  case 'usage': { const c = evt.ctx; return c ? w(`${A.dim} ctx ${c.pct}% · ${Math.round(c.used / 1000)}k/${Math.round(c.max / 1000)}k${evt.costUsd != null ? ` · $${evt.costUsd.toFixed(3)}` : ''}${A.rst}`) : undefined }
@@ -1091,7 +1099,7 @@ channel
1091
1099
  markActivity()
1092
1100
  const text = String(payload.text)
1093
1101
  // Echo the turn so BOTH readers (and late joiners, via the log) show who
1094
- // said what — UNLESS silent (an @pool synthesis, which renders its own line).
1102
+ // said what — UNLESS silent (an @pool fire, which renders its own line).
1095
1103
  const echoYou = () => {
1096
1104
  if (payload.silent) return
1097
1105
  // Display the clean body (web sends `body` when the agent `text` carries host
@@ -1181,7 +1189,7 @@ channel
1181
1189
  .subscribe(status => {
1182
1190
  if (status === 'SUBSCRIBED') {
1183
1191
  realtimeHealthy = true; brokenSince = 0
1184
- channel.track({ name, role: 'bridge' })
1192
+ trackPresence({ name, role: 'bridge' })
1185
1193
  const startCmd = attachedCmd || autoAgent // autoAgent: account-mode auto-open (headless)
1186
1194
  if (startCmd && !terms.size && !sessions.size) {
1187
1195
  // Claude + structured mode → Agent SDK session; everything else → PTY.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.60",
3
+ "version": "0.7.61",
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": {
@@ -16,6 +16,7 @@
16
16
  "session-store.mjs",
17
17
  "service.mjs",
18
18
  "cmux.mjs",
19
+ "presence.mjs",
19
20
  "account.mjs",
20
21
  "auth-store.mjs",
21
22
  "provider.mjs",
package/presence.mjs ADDED
@@ -0,0 +1,51 @@
1
+ /* ─────────────────────────────────────────────────────────────
2
+ presence.mjs — a throttled wrapper for Supabase Realtime presence
3
+ track(), shared by the account supervisor (account.mjs) and every
4
+ room bridge (bridge.mjs).
5
+
6
+ The bug it fixes: supabase-js auto-rejoins a dropped channel and
7
+ re-fires `SUBSCRIBED`; our subscribe handlers re-`track()` presence
8
+ on each one. With no throttle a SINGLE drop can spiral —
9
+ track() → trip the per-client presence rate limit
10
+ → server closes the channel → auto-rejoin → SUBSCRIBED
11
+ → track() → trip again → … forever
12
+ which the server logs as `ClientPresenceRateLimitReached:
13
+ :client_rate_limit_exceeded`. The supervisor stays alive and keeps
14
+ relaying sessions, but its presence never re-sticks, so the dashboard
15
+ reads "No bridge connected" while sessions plainly run (the
16
+ 2026-06-17 / -22 / -24 recurrences).
17
+
18
+ makeThrottledTrack coalesces bursts to at most one track() per
19
+ window: the FIRST call fires immediately (presence appears fast),
20
+ any further calls inside the window collapse into a single trailing
21
+ fire with the latest payload. At minMs=5000 that's ≤0.2 track/s —
22
+ far under any presence rate limit — so a reconnect storm can never
23
+ trip it, while the steady-state callers (a 15s tick, a token refresh)
24
+ are unaffected (their interval already exceeds the window).
25
+ ───────────────────────────────────────────────────────────── */
26
+
27
+ // channel: a supabase RealtimeChannel (stable across reconnects — supabase-js
28
+ // rejoins the same object, so channel.track stays valid). Returns a function you
29
+ // call with the presence payload anywhere you'd have called channel.track(payload).
30
+ export function makeThrottledTrack(channel, { minMs = 5000 } = {}) {
31
+ let last = 0
32
+ let timer = null
33
+ let pending = null
34
+ const fire = () => {
35
+ timer = null
36
+ last = Date.now()
37
+ const payload = pending
38
+ pending = null
39
+ try { channel.track(payload) } catch { /* presence track is best-effort */ }
40
+ }
41
+ return (payload) => {
42
+ pending = payload
43
+ const since = Date.now() - last
44
+ if (since >= minMs) {
45
+ if (timer) { clearTimeout(timer) }
46
+ fire() // leading edge — presence appears immediately
47
+ } else if (!timer) {
48
+ timer = setTimeout(fire, minMs - since) // trailing edge — coalesce the burst
49
+ }
50
+ }
51
+ }