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 +27 -3
- 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
|
|
|
@@ -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
|
-
|
|
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(
|
|
331
|
-
setTimeout(() => process.exit(
|
|
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
|
|
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
|
+
}
|