thinkpool-pair 0.7.30 → 0.7.31

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 +33 -9
  2. package/event-id.mjs +40 -0
  3. package/package.json +1 -1
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 } from './event-id.mjs'
42
+ import { stampEvent, makeSeqCounter, maxSeq, seqable } from './event-id.mjs'
43
43
 
44
44
  // Public client creds (the same anon values the web app ships — safe to embed).
45
45
  // Override with TP_SUPABASE_URL / TP_SUPABASE_ANON if you ever need to.
@@ -615,6 +615,20 @@ function printLocal(evt) {
615
615
  }
616
616
  }
617
617
 
618
+ // Single stamp+append point for a structured session's replayed log. Assigns the
619
+ // per-term contiguous seq (C1), appends, and caps at STRUCTURED_LOG_MAX (oldest
620
+ // first). EVERY event that joins entry.log must go through here so the client's
621
+ // "drop ≤ seqHi" cursor stays correct (a logged event with no seq would break it).
622
+ function pushLog(entry, evt) {
623
+ // Only TRANSCRIPT events consume a seq (seqable) — replay-chrome kinds the web
624
+ // filters out (system/thinking_tokens/effort) ride the log seq-less so they don't
625
+ // hole the client's applied sequence. See event-id.mjs NO_SEQ_KINDS.
626
+ if (entry.seq && seqable(evt)) evt.seq = entry.seq.next()
627
+ entry.log.push(evt)
628
+ if (entry.log.length > STRUCTURED_LOG_MAX) entry.log.shift()
629
+ return evt
630
+ }
631
+
618
632
  // The Phase-2 path: instead of a PTY, run Claude Code through the Agent SDK and
619
633
  // relay STRUCTURED events. onEvent → broadcast `code-event` + print locally +
620
634
  // persist to the host file; tool calls round-trip through the perm card; the
@@ -622,6 +636,11 @@ function printLocal(evt) {
622
636
  function openStructured({ id, model, resume, log, commands, mode }) {
623
637
  if (sessions.has(id)) return
624
638
  const entry = { cmd: 'claude', kind: 'structured', log: Array.isArray(log) ? log.slice(-STRUCTURED_LOG_MAX) : [], pending: new Map(), session: null, recovered: false, commands: Array.isArray(commands) ? commands : undefined, mode }
639
+ // C1 (RT-2): per-term contiguous seq counter. Seeded from the restored log's max
640
+ // so a bridge restart resumes ABOVE every persisted seq — fresh events never reuse
641
+ // an old seq, which is what keeps the between-turns restart dup-free (see
642
+ // event-id.mjs + src/pages/code/seqDedup.js). pushLog() is the single stamp point.
643
+ entry.seq = makeSeqCounter(maxSeq(entry.log))
625
644
  sessions.set(id, entry)
626
645
  // This session's private mockup outbox — render.sh writes manifests here (via
627
646
  // TP_MOCKUP_OUTBOX below) and the watcher attributes every card to THIS id, so
@@ -671,10 +690,7 @@ function openStructured({ id, model, resume, log, commands, mode }) {
671
690
  entry.compacting = false
672
691
  bcast('code-event', { term: id, evt: { kind: 'compact', status: 'done', ts: Date.now() } })
673
692
  }
674
- if (!chrome) {
675
- entry.log.push(evt)
676
- if (entry.log.length > STRUCTURED_LOG_MAX) entry.log.shift()
677
- }
693
+ if (!chrome) pushLog(entry, evt)
678
694
  bcast('code-event', { term: id, evt })
679
695
  printLocal(evt)
680
696
  if (!chrome) persist()
@@ -831,9 +847,17 @@ channel
831
847
  })
832
848
  }
833
849
  // Structured sessions replay their event log (reader rebuilds from it).
850
+ // C1 (RT-2): send only the tail past the client's per-term cursor (seqHi) when
851
+ // it supplies one — the authoritative replay-from-cursor that supersedes
852
+ // re-sending + re-merging the whole log on every reconnect. Events with no seq
853
+ // (legacy/pre-C1 logs) ride along always; the client cid-dedups those and
854
+ // additionally drops ≤ seqHi, so an over-send is harmless. A fresh joiner (no
855
+ // cursor) still gets the full log.
834
856
  for (const [id, s] of sessions) {
835
857
  if (!s.log.length) continue
836
- bcast('code-replay', { to: payload?.to ?? null, term: id, events: s.log })
858
+ const from = Number(payload?.cursors?.[id]) || 0
859
+ const events = from > 0 ? s.log.filter((e) => e.seq == null || e.seq > from) : s.log
860
+ bcast('code-replay', { to: payload?.to ?? null, term: id, events })
837
861
  }
838
862
  // Re-send any still-pending permission/question cards. They ride a one-shot
839
863
  // code-perm-req broadcast (NOT the replayed event log), so a reconnect/refresh
@@ -881,7 +905,7 @@ channel
881
905
  const echoYou = () => {
882
906
  if (payload.silent) return
883
907
  const evt = { kind: 'you', text, cid: payload.cid, by: payload.by }
884
- s.log.push(evt); if (s.log.length > STRUCTURED_LOG_MAX) s.log.shift()
908
+ pushLog(s, evt)
885
909
  bcast('code-event', { term: payload.term, evt })
886
910
  }
887
911
  // Session controls (/model, /clear) are NOT agent turns — they change the
@@ -891,7 +915,7 @@ channel
891
915
  // slash still flow through as a real, self-terminating turn.
892
916
  const ctlLine = (ctlText) => {
893
917
  const evt = { kind: 'control', text: ctlText, by: payload.by, cid: payload.cid }
894
- s.log.push(evt); if (s.log.length > STRUCTURED_LOG_MAX) s.log.shift()
918
+ pushLog(s, evt)
895
919
  bcast('code-event', { term: payload.term, evt })
896
920
  }
897
921
  const mm = text.match(/^\/model\b\s*(\S+)?/)
@@ -902,7 +926,7 @@ channel
902
926
  }
903
927
  if (/^\/clear\s*$/.test(text)) {
904
928
  s.session.sendTurn(text)
905
- s.log = []; bcast('code-event', { term: payload.term, evt: { kind: 'clear' } })
929
+ s.log = []; s.seq = makeSeqCounter(0); bcast('code-event', { term: payload.term, evt: { kind: 'clear' } })
906
930
  flushSession(room, payload.term, { sessionId: s.session?.sessionId || null, log: [] })
907
931
  ctlLine('context cleared. You can continue with these answers in mind.')
908
932
  return
package/event-id.mjs CHANGED
@@ -27,3 +27,43 @@ export function stampEvent(evt, gen = randomUUID) {
27
27
  if (!evt.cid) evt.cid = gen()
28
28
  return evt
29
29
  }
30
+
31
+ /* C1 (RT-2): a per-term monotonic, CONTIGUOUS sequence counter. Every event that
32
+ enters a session's replayed log gets `seq = counter.next()`, so the web room can
33
+ replay-from-cursor and dedup by "drop ≤ seqHi" instead of re-merging the whole
34
+ log by cid on every reconnect (see src/pages/code/seqDedup.js).
35
+
36
+ THE restart property (what makes Slice 3's between-turns restart dup-free): when a
37
+ bridge restart restores a persisted log, seed the counter from maxSeq(log) so the
38
+ NEXT seq is strictly above every restored seq. Reusing an old seq would make the
39
+ client drop the fresh event as a duplicate. */
40
+ export function makeSeqCounter(startAfter = 0) {
41
+ let n = Number.isFinite(startAfter) ? startAfter : 0
42
+ return {
43
+ next: () => ++n,
44
+ bumpTo: (m) => { if (Number.isFinite(m) && m > n) n = m },
45
+ current: () => n,
46
+ }
47
+ }
48
+
49
+ // Kinds the bridge logs for the live transcript but that the WEB treats as
50
+ // replay-chrome (src/pages/code/code-constants.js REPLAY_CHROME): filtered out of
51
+ // the replay + handled as transient state, never applied as a transcript line. They
52
+ // must NOT consume a per-term seq — the client advances its cursor (seqHi) ONLY over
53
+ // transcript events, so a seq on one of these would leave a hole in the applied
54
+ // sequence → the client sees a false gap → spurious resync storms. So the counter
55
+ // skips them and they ride the log seq-less (the client cid-handles/ignores them).
56
+ // MUST stay a subset of REPLAY_CHROME ∩ {kinds the bridge logs} — guarded by
57
+ // bridge/test-seq.mjs S6. (mode/usage/clear/compact/stalled are bridge-chrome and
58
+ // never logged at all, so they never reach the counter.)
59
+ export const NO_SEQ_KINDS = new Set(['system', 'thinking_tokens', 'effort'])
60
+ export const seqable = (evt) => !!evt && typeof evt === 'object' && !NO_SEQ_KINDS.has(evt.kind)
61
+
62
+ // Highest numeric `seq` across a log of events (0 when none) — the floor a restored
63
+ // session's counter resumes above.
64
+ export function maxSeq(events) {
65
+ if (!Array.isArray(events)) return 0
66
+ let m = 0
67
+ for (const e of events) if (e && typeof e.seq === 'number' && e.seq > m) m = e.seq
68
+ return m
69
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.30",
3
+ "version": "0.7.31",
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": {