thinkpool-pair 0.7.27 → 0.7.28
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/bridge.mjs +60 -22
- package/event-id.mjs +29 -0
- package/package.json +2 -1
package/bridge.mjs
CHANGED
|
@@ -37,6 +37,7 @@ import { randomUUID } from 'node:crypto'
|
|
|
37
37
|
import { createClient } from '@supabase/supabase-js'
|
|
38
38
|
import { startClaudeSession } from './claude-session.mjs'
|
|
39
39
|
import { saveSession, flushSession, deleteSession, loadAll, canResume, loadPtyId, savePtyId, loadNames, saveNames } from './session-store.mjs'
|
|
40
|
+
import { stampEvent } from './event-id.mjs'
|
|
40
41
|
|
|
41
42
|
// Public client creds (the same anon values the web app ships — safe to embed).
|
|
42
43
|
// Override with TP_SUPABASE_URL / TP_SUPABASE_ANON if you ever need to.
|
|
@@ -588,11 +589,13 @@ function openStructured({ id, model, resume, log, commands, mode }) {
|
|
|
588
589
|
openStructured({ id, model, log: entry.log, commands: entry.commands, mode: entry.mode })
|
|
589
590
|
return
|
|
590
591
|
}
|
|
591
|
-
// Stamp a wall-clock ts on every transcript event
|
|
592
|
-
//
|
|
593
|
-
//
|
|
594
|
-
//
|
|
595
|
-
|
|
592
|
+
// Stamp a wall-clock ts AND a stable cid on every transcript event before
|
|
593
|
+
// it's logged + broadcast. ts: lets the web client sort agent turns
|
|
594
|
+
// chronologically against room chat/whispers on reload. cid: the room's
|
|
595
|
+
// replay-union dedupes ONLY by cid, and SDK events carry none — without an
|
|
596
|
+
// id, an event that arrives both live AND in a reconnect replay renders
|
|
597
|
+
// twice (the 2026-06-19 duplicate-message bug). See event-id.mjs.
|
|
598
|
+
stampEvent(evt)
|
|
596
599
|
// The init system event carries the session's slash command list. Stash it
|
|
597
600
|
// on the entry so the ANNOUNCE can hand it to clients that connect/reload
|
|
598
601
|
// AFTER init (the one-time code-event would miss them), then re-announce.
|
|
@@ -637,10 +640,22 @@ function openStructured({ id, model, resume, log, commands, mode }) {
|
|
|
637
640
|
// from memory, delete its on-disk record (so a restart can't resurrect it), and tell
|
|
638
641
|
// every client it's gone. Idempotent + id-scoped — no-ops for ids that aren't a
|
|
639
642
|
// structured session, so it's safe to call from the PTY-oriented term-close path too.
|
|
643
|
+
// Resolve any unanswered permission resolvers to 'deny' and clear them. A card
|
|
644
|
+
// that's never answered (both clients gone, tab closed, or an abort) otherwise
|
|
645
|
+
// leaves the SDK's PreToolUse hook (claude-session.mjs) awaiting our promise
|
|
646
|
+
// forever — the structured turn freezes mid-flight. 'deny' is the fail-safe the
|
|
647
|
+
// hook already degrades to, so draining to deny is always safe.
|
|
648
|
+
function drainPending(s) {
|
|
649
|
+
if (!s?.pending) return
|
|
650
|
+
for (const [, p] of s.pending) { try { p.resolve('deny') } catch { /* noop */ } }
|
|
651
|
+
s.pending.clear()
|
|
652
|
+
}
|
|
653
|
+
|
|
640
654
|
function endStructured(id) {
|
|
641
655
|
if (!id) return
|
|
642
656
|
const s = sessions.get(id)
|
|
643
657
|
if (s) {
|
|
658
|
+
drainPending(s)
|
|
644
659
|
try { s.session?.end() } catch { /* noop */ }
|
|
645
660
|
try { s.mockupWatcher?.close() } catch { /* noop */ }
|
|
646
661
|
sessions.delete(id)
|
|
@@ -843,7 +858,7 @@ channel
|
|
|
843
858
|
})
|
|
844
859
|
.on('broadcast', { event: 'code-abort' }, ({ payload }) => {
|
|
845
860
|
const s = payload?.term && sessions.get(payload.term)
|
|
846
|
-
if (s) s.session.abort()
|
|
861
|
+
if (s) { drainPending(s); s.session.abort() } // settle any open permission card so the hook doesn't hang
|
|
847
862
|
})
|
|
848
863
|
.on('broadcast', { event: 'code-mode' }, ({ payload }) => {
|
|
849
864
|
const s = payload?.term && sessions.get(payload.term)
|
|
@@ -916,7 +931,7 @@ channel
|
|
|
916
931
|
setInterval(() => {
|
|
917
932
|
if (!realtimeHealthy && brokenSince && Date.now() - brokenSince > 60000) {
|
|
918
933
|
process.stderr.write('\n ⚠ realtime wedged >60s — exiting for a clean restart.\n')
|
|
919
|
-
|
|
934
|
+
shutdown(1) // reap PTYs + attempt presence-leave (was a raw exit → orphaned children)
|
|
920
935
|
}
|
|
921
936
|
}, 15000).unref()
|
|
922
937
|
|
|
@@ -960,7 +975,7 @@ if (process.env.THINKPOOL_PAIR_AUTOUPDATE === '1' && VERSION) {
|
|
|
960
975
|
const applyIfIdle = () => {
|
|
961
976
|
if (!pendingUpdate || !isIdle()) return
|
|
962
977
|
process.stderr.write(`\n ◆ thinkpool-pair ${pendingUpdate} ready (running ${VERSION}) — restarting to update; the session resumes.\n`)
|
|
963
|
-
|
|
978
|
+
shutdown(0, false) // clean teardown (no farewell banner); service reruns @latest, session-store resumes
|
|
964
979
|
}
|
|
965
980
|
const check = async () => {
|
|
966
981
|
const latest = await fetchLatest()
|
|
@@ -1010,24 +1025,47 @@ if (process.env.THINKPOOL_PAIR_AUTOUPDATE === '1' && VERSION) {
|
|
|
1010
1025
|
}, 60000).unref()
|
|
1011
1026
|
}
|
|
1012
1027
|
|
|
1013
|
-
|
|
1028
|
+
// code: process exit code. farewell: print the "[ shared session ended ]" banner
|
|
1029
|
+
// (suppressed for an auto-update restart, which is meant to be invisible in-room).
|
|
1030
|
+
async function shutdown(code = 0, farewell = true) {
|
|
1014
1031
|
if (shuttingDown) return
|
|
1015
1032
|
shuttingDown = true
|
|
1033
|
+
// Hard exit backstop, armed FIRST and independent of every await below — a wedged
|
|
1034
|
+
// realtime socket (the watchdog path) or a hung untrack must NEVER leave the
|
|
1035
|
+
// process alive with orphaned PTY children + stale "live" presence.
|
|
1036
|
+
setTimeout(() => process.exit(code), 1500)
|
|
1016
1037
|
clearInterval(flushTimer)
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
//
|
|
1024
|
-
//
|
|
1025
|
-
|
|
1026
|
-
|
|
1038
|
+
if (farewell) {
|
|
1039
|
+
try {
|
|
1040
|
+
const bye = Buffer.from('\r\n[ shared session ended ]\r\n', 'utf8').toString('base64')
|
|
1041
|
+
for (const id of terms.keys()) bcast('pty-out', { term: id, b64: bye })
|
|
1042
|
+
} catch { /* noop */ }
|
|
1043
|
+
}
|
|
1044
|
+
// Reap children FIRST — killing PTYs / ending SDK sessions is unconditionally
|
|
1045
|
+
// correct and must not hang on the (possibly dead) realtime socket. This is the
|
|
1046
|
+
// bug behind the watchdog/auto-update raw process.exit() paths, which skipped
|
|
1047
|
+
// teardown entirely and orphaned agent processes + left stale presence.
|
|
1027
1048
|
for (const t of terms.values()) { try { t.term.kill() } catch { /* noop */ } }
|
|
1028
1049
|
for (const s of sessions.values()) { try { s.session.end() } catch { /* noop */ } }
|
|
1029
1050
|
detachLocal()
|
|
1030
|
-
|
|
1051
|
+
// Best-effort presence leave so the room flips to dormant immediately (when the
|
|
1052
|
+
// socket is alive; on a wedge it won't flush and the hard backstop above wins).
|
|
1053
|
+
try { await channel.untrack() } catch { /* noop */ }
|
|
1054
|
+
try { await supabase.removeChannel(channel) } catch { /* noop */ }
|
|
1055
|
+
setTimeout(() => process.exit(code), 250) // grace for the leave/close frames to flush
|
|
1031
1056
|
}
|
|
1032
|
-
process.on('SIGINT', shutdown)
|
|
1033
|
-
process.on('SIGTERM', shutdown)
|
|
1057
|
+
process.on('SIGINT', () => shutdown(0))
|
|
1058
|
+
process.on('SIGTERM', () => shutdown(0))
|
|
1059
|
+
// A throw escaping any async callback (an onEvent consumer, a JSON-serialize on a
|
|
1060
|
+
// circular payload, a node-pty native error) would otherwise terminate the process
|
|
1061
|
+
// with PTYs orphaned and presence stuck "live" (Node terminates on both since v15).
|
|
1062
|
+
// Route both through shutdown() so children are reaped + presence left, then the
|
|
1063
|
+
// supervisor / launchd / systemd respawns a clean process.
|
|
1064
|
+
process.on('uncaughtException', (err) => {
|
|
1065
|
+
try { process.stderr.write(`\n ⚠ uncaughtException: ${err?.stack || err}\n ◆ shutting down cleanly for a fresh respawn.\n`) } catch { /* noop */ }
|
|
1066
|
+
shutdown(1)
|
|
1067
|
+
})
|
|
1068
|
+
process.on('unhandledRejection', (reason) => {
|
|
1069
|
+
try { process.stderr.write(`\n ⚠ unhandledRejection: ${reason?.stack || reason}\n ◆ shutting down cleanly for a fresh respawn.\n`) } catch { /* noop */ }
|
|
1070
|
+
shutdown(1)
|
|
1071
|
+
})
|
package/event-id.mjs
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
/* event-id.mjs — stamp a structured event with a stable identity + timestamp
|
|
4
|
+
BEFORE it is logged + broadcast.
|
|
5
|
+
|
|
6
|
+
WHY (the duplicate-message bug, 2026-06-19): structured transcript events
|
|
7
|
+
(assistant / tool_result / thinking / result) carry NO id from the Claude
|
|
8
|
+
Agent SDK. The web room's replay-union (src/pages/code/room.jsx, the
|
|
9
|
+
`code-replay` handler) dedupes ONLY by `cid`: an event with a cid is merged
|
|
10
|
+
once; an event WITHOUT a cid is appended unconditionally. So an id-less event
|
|
11
|
+
that arrives both LIVE (`code-event`) and again in a reconnect REPLAY
|
|
12
|
+
(`code-replay`, which re-sends the whole rolling log) is rendered TWICE.
|
|
13
|
+
On mobile a background/foreground or network blip routinely triggers that
|
|
14
|
+
replay — which is exactly when the duplicates appeared.
|
|
15
|
+
|
|
16
|
+
Fix at the source: give every structured event a stable `cid` here, once, so
|
|
17
|
+
the live copy and every replayed copy share an id and the room's existing
|
|
18
|
+
cid-union collapses them. Idempotent — never overwrites a cid the client
|
|
19
|
+
already set (chat / whisper / pool / propose carry their own).
|
|
20
|
+
|
|
21
|
+
Kept as its own import-safe module so it can be unit-tested
|
|
22
|
+
(bridge/test-structured-cid.mjs); bridge.mjs runs the bridge on import and
|
|
23
|
+
cannot be imported by a test. */
|
|
24
|
+
export function stampEvent(evt, gen = randomUUID) {
|
|
25
|
+
if (!evt || typeof evt !== 'object') return evt
|
|
26
|
+
if (typeof evt.ts !== 'number') evt.ts = Date.now()
|
|
27
|
+
if (!evt.cid) evt.cid = gen()
|
|
28
|
+
return evt
|
|
29
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "thinkpool-pair",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.28",
|
|
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": {
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
"bridge.mjs",
|
|
11
11
|
"claude-session.mjs",
|
|
12
|
+
"event-id.mjs",
|
|
12
13
|
"transcript-sanitize.mjs",
|
|
13
14
|
"session-store.mjs",
|
|
14
15
|
"service.mjs",
|