thinkpool-pair 0.7.59 → 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
 
@@ -223,6 +224,7 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
223
224
  // codes + version) on a per-account channel; the dashboard subscribes read-only.
224
225
  const machine = os.hostname().replace(/\.local$/, '')
225
226
  const acct = sb.channel(`tpacct:${session.user.id}`, { config: { presence: { key: machine }, broadcast: { self: false } } })
227
+ let restarting = false // set by the "restart" broadcast → stop() exits non-zero so a supervisor respawns us
226
228
  // Dashboard "Disconnect" → broadcast shutdown → clean exit (presence leaves, bar flips live).
227
229
  // If we're the auto-restarting background service (launchd KeepAlive / systemd
228
230
  // Restart=always sets AUTOUPDATE=1), a bare exit would be respawned ~2s later —
@@ -244,7 +246,26 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
244
246
  }
245
247
  process.kill(process.pid, 'SIGTERM')
246
248
  })
247
- const pushPresence = () => { try { acct.track({ name: machine, version: VERSION, rooms: [...children.keys()], ts: Date.now() }) } catch { /* noop */ } }
249
+ // Dashboard "Restart" → broadcast restart → graceful stop with a NON-ZERO exit so
250
+ // EVERY supervisor respawns us: launchd KeepAlive / systemd Restart=always respawn
251
+ // regardless of exit code, and the bridge.mjs --supervise wrappers (cmux tier, room
252
+ // tier) respawn on non-zero but treat 0 as a clean stop. Children persist + resume
253
+ // their sessions (session-store), so the bounce is invisible in-room. Unlike shutdown
254
+ // we do NOT uninstall the service. A plain foreground bridge has no supervisor, so it
255
+ // just stops — restart is meaningful for the always-on bridge. Old bridges (pre-0.7.60)
256
+ // don't subscribe to this event, so the dashboard button is a harmless no-op there.
257
+ acct.on('broadcast', { event: 'restart' }, () => {
258
+ process.stderr.write('\n ◇ restart requested from the dashboard — bouncing bridge (sessions resume).\n')
259
+ restarting = true
260
+ setTimeout(() => { try { releaseSingleton() } catch { /* noop */ } process.exit(1) }, 2500)
261
+ process.kill(process.pid, 'SIGTERM')
262
+ })
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() })
248
269
  // Re-track on EVERY (re)subscribe, not just the first: a realtime reconnect
249
270
  // (network blip, or a token swap mid-flight) rejoins the channel and must
250
271
  // re-announce, or the dashboard would read "no bridge" until the next restart.
@@ -319,16 +340,19 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
319
340
  await tick()
320
341
  const iv = setInterval(tick, 15000)
321
342
  let stopping = false
343
+ // restart-vs-stop is the ONLY difference in exit code: a web "restart" sets this so
344
+ // we exit non-zero (every supervisor respawns); a normal stop/shutdown exits 0.
322
345
  const stop = (sig) => {
323
346
  if (stopping) return; stopping = true
347
+ const code = restarting ? 1 : 0
324
348
  clearInterval(iv)
325
349
  keepFresh.cancel()
326
350
  releaseSingleton()
327
351
  for (const c of children.values()) { try { c.kill(sig || 'SIGTERM') } catch { /* noop */ } }
328
352
  // Flush the presence LEAVE before exiting so the dashboard flips to
329
353
  // "not connected" in realtime (don't fire-and-forget the untrack).
330
- ;(async () => { try { await acct.untrack() } catch { /* noop */ } try { await sb.removeChannel(acct) } catch { /* noop */ } process.exit(0) })()
331
- setTimeout(() => process.exit(0), 1500) // hard backstop if the flush hangs
354
+ ;(async () => { try { await acct.untrack() } catch { /* noop */ } try { await sb.removeChannel(acct) } catch { /* noop */ } process.exit(code) })()
355
+ setTimeout(() => process.exit(code), 1500) // hard backstop if the flush hangs
332
356
  }
333
357
  for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.on(sig, () => stop(sig))
334
358
 
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.59",
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
+ }