thinkpool-pair 0.7.65 → 0.7.67

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
@@ -270,9 +270,27 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
270
270
  // just stops — restart is meaningful for the always-on bridge. Old bridges (pre-0.7.60)
271
271
  // don't subscribe to this event, so the dashboard button is a harmless no-op there.
272
272
  acct.on('broadcast', { event: 'restart' }, () => {
273
+ const supervised = process.env.THINKPOOL_PAIR_AUTOUPDATE === '1'
273
274
  process.stderr.write('\n ◇ restart requested from the dashboard — bouncing bridge (sessions resume).\n')
274
275
  restarting = true
275
- setTimeout(() => { try { releaseSingleton() } catch { /* noop */ } process.exit(1) }, 2500)
276
+ setTimeout(() => {
277
+ try { releaseSingleton() } catch { /* noop */ }
278
+ // A SUPERVISED bridge (launchd KeepAlive / systemd Restart=always / the
279
+ // bridge.mjs --supervise wrapper) is respawned by its supervisor on exit, so a
280
+ // bare exit(1) bounces cleanly. A plain FOREGROUND bridge has NO supervisor —
281
+ // there exit = death and the dashboard "Restart" becomes a footgun (2026-06-25:
282
+ // Max's manual `npx thinkpool-pair` died on Restart and never came back). So when
283
+ // unsupervised, re-spawn ourselves DETACHED *after* releasing the machine lock
284
+ // (the child's acquireSingleton() then sees no live owner and takes it). The child
285
+ // has no TTY → bridge.mjs routes a bare invocation straight to account mode. Gated
286
+ // on `supervised` so we never double-spawn (supervisor + self) into two bridges.
287
+ if (!supervised) {
288
+ try {
289
+ spawn(process.execPath, [process.argv[1]], { detached: true, stdio: 'ignore', env: process.env }).unref()
290
+ } catch { /* respawn failed → exit anyway, no worse than the old behaviour */ }
291
+ }
292
+ process.exit(1)
293
+ }, 2500)
276
294
  process.kill(process.pid, 'SIGTERM')
277
295
  })
278
296
  // Throttled so a realtime reconnect storm can't machine-gun track() into the
package/bridge.mjs CHANGED
@@ -39,7 +39,7 @@ import { createClient } from '@supabase/supabase-js'
39
39
  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
- import { stampEvent, makeSeqCounter, maxSeq, seqable } from './event-id.mjs'
42
+ import { stampEvent, makeSeqCounter, maxSeq, seqable, capReplayEvents } from './event-id.mjs'
43
43
  import { makeThrottledTrack } from './presence.mjs'
44
44
 
45
45
  // Public client creds (the same anon values the web app ships — safe to embed).
@@ -975,9 +975,11 @@ channel
975
975
  flushAll()
976
976
  for (const [id, t] of terms) {
977
977
  if (!t.scrollback) continue
978
+ // Cap scrollback too — a huge PTY buffer would blow the same frame limit.
979
+ const sb = t.scrollback.length > 100000 ? t.scrollback.slice(-100000) : t.scrollback
978
980
  bcast('pty-replay', {
979
981
  to: payload?.to ?? null, term: id,
980
- b64: Buffer.from(t.scrollback, 'utf8').toString('base64'),
982
+ b64: Buffer.from(sb, 'utf8').toString('base64'),
981
983
  })
982
984
  }
983
985
  // Structured sessions replay their event log (reader rebuilds from it).
@@ -990,7 +992,10 @@ channel
990
992
  for (const [id, s] of sessions) {
991
993
  if (!s.log.length) continue
992
994
  const from = Number(payload?.cursors?.[id]) || 0
993
- const events = from > 0 ? s.log.filter((e) => e.seq == null || e.seq > from) : s.log
995
+ const tail = from > 0 ? s.log.filter((e) => e.seq == null || e.seq > from) : s.log
996
+ // Cap to a frame-safe size — the full log on a fresh-reload (empty cursor) is
997
+ // multi-MB and gets dropped by Realtime, freezing the terminal. See capReplayEvents.
998
+ const events = capReplayEvents(tail)
994
999
  bcast('code-replay', { to: payload?.to ?? null, term: id, events })
995
1000
  }
996
1001
  // Re-send any still-pending permission/question cards. They ride a one-shot
package/event-id.mjs CHANGED
@@ -67,3 +67,29 @@ export function maxSeq(events) {
67
67
  for (const e of events) if (e && typeof e.seq === 'number' && e.seq > m) m = e.seq
68
68
  return m
69
69
  }
70
+
71
+ // Cap a replay event list to a byte budget so the broadcast frame fits under the
72
+ // Realtime size limit. THE BUG (2026-06-26, room XE3IQ): on a fresh reload the client
73
+ // sends replay-request with an EMPTY cursor (seqHi=0), so the bridge replied with the
74
+ // ENTIRE per-term log — multi-thousand events / multiple MB for an established terminal.
75
+ // That frame exceeds Realtime's broadcast size limit and is silently DROPPED (and breaks
76
+ // the socket for the rest of the tick, taking small terminals' replays + the CHANNEL_ERROR
77
+ // churn down with it). The terminal then never refills and every live event is gap-dropped
78
+ // → "messages vanish, agent never responds, only brand-new terminals work".
79
+ // Keep the MOST RECENT events that fit: the client's DB transcript snapshot (code_messages
80
+ // tx:, last 200) already renders older history, and seqDedup.classifyReplay explicitly
81
+ // accepts the seq eviction-jump, so the client's live cursor (seqHi) still reaches current.
82
+ // Empirically a ~266KB frame delivered while multi-MB did not; 150KB is a safe margin.
83
+ // Always keeps at least the newest event so seqHi can advance even if it alone is oversized.
84
+ export const REPLAY_BYTE_BUDGET = 150000
85
+ export function capReplayEvents(events, budget = REPLAY_BYTE_BUDGET) {
86
+ if (!Array.isArray(events) || events.length === 0) return events
87
+ let total = 0, i = events.length
88
+ while (i > 0) {
89
+ const sz = JSON.stringify(events[i - 1]).length + 1
90
+ if (i < events.length && total + sz > budget) break
91
+ total += sz
92
+ i--
93
+ }
94
+ return i === 0 ? events : events.slice(i)
95
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.65",
3
+ "version": "0.7.67",
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": {