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.
Files changed (3) hide show
  1. package/bridge.mjs +60 -22
  2. package/event-id.mjs +29 -0
  3. 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 so the web client can
592
- // sort agent turns chronologically against room chat/whispers on reload
593
- // (the client merges the replayed log with persisted human lines). Used
594
- // for ordering only agent timestamps are never displayed.
595
- if (typeof evt.ts !== 'number') evt.ts = Date.now()
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
- process.exit(1)
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
- process.exit(0) // service restarts with @latest; session-store resumes
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
- async function shutdown() {
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
- try {
1018
- const bye = Buffer.from('\r\n[ shared session ended ]\r\n', 'utf8').toString('base64')
1019
- for (const id of terms.keys()) bcast('pty-out', { term: id, b64: bye })
1020
- } catch { /* noop */ }
1021
- // Leave presence EXPLICITLY before exiting so the web room flips to dormant
1022
- // immediately. Previously we removeChannel()'d and process.exit()'d on the next
1023
- // line the leave frame never flushed, so the web only noticed via the realtime
1024
- // heartbeat timeout (tens of seconds later) and looked stuck "live".
1025
- try { await channel.untrack() } catch { /* noop */ }
1026
- try { await supabase.removeChannel(channel) } catch { /* noop */ }
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
- setTimeout(() => process.exit(0), 250) // small grace for the leave/close frames to flush over the socket
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.27",
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",