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 +19 -1
- package/bridge.mjs +8 -3
- package/event-id.mjs +26 -0
- package/package.json +1 -1
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(() => {
|
|
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(
|
|
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
|
|
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
|
+
}
|