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 +7 -1
- package/bridge.mjs +11 -3
- package/package.json +2 -1
- package/presence.mjs +51 -0
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
+
}
|