thinkpool-pair 0.7.185 → 0.7.186

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 +35 -9
  2. package/event-id.mjs +33 -0
  3. package/package.json +1 -1
package/bridge.mjs CHANGED
@@ -95,7 +95,7 @@ const flowBudgets = new Map()
95
95
  import { formatPeek, PEEK, siblingsOf, resolveSibling, crossPostDecision, CROSSPOST, spawnDecision, SPAWN, CROSSROOM, formatPairRoster, crossRoomPostDecision, formatRoomNow } from './cross-terminal.mjs'
96
96
  import { turnInFlight } from './update-gate.mjs'
97
97
  import { saveSession, flushSession, deleteSession, loadAll, canResume, loadPtyId, savePtyId, loadNames, saveNames, appendDurableEvents, seedDurableEvents, readDurablePage, readDurableOldestSeq } from './session-store.mjs'
98
- import { stampEvent, makeSeqCounter, maxSeq, seqable, capReplayEvents, chunkReplayEvents, boundEventForBroadcast, inlineImageBlocks, usageReportLine, buildRecapFromLog, RECAP_CAP } from './event-id.mjs'
98
+ import { stampEvent, makeSeqCounter, maxSeq, seqable, capReplayEvents, chunkReplayEvents, boundEventForBroadcast, inlineImageBlocks, usageReportLine, buildRecapFromLog, RECAP_CAP, trimmedBeforeSeq, firstSeq } from './event-id.mjs'
99
99
  import { planMeterLine } from './plan-meters.mjs'
100
100
  import { makeThrottledTrack } from './presence.mjs'
101
101
 
@@ -929,10 +929,23 @@ const defendFrame = (event, payload) => {
929
929
  if (Array.isArray(p.events)) {
930
930
  p.events = p.events.map((e) => boundEventForBroadcast(e, 100000))
931
931
  // …and if a frame is STILL over the limit, drop the OLDEST events until it fits —
932
- // keep the newest so the client's seqHi still advances to current. classifyReplay
933
- // accepts the resulting seq jump; older events are covered by the DB transcript snapshot.
934
- let guard = 0
935
- while (p.events.length > 1 && JSON.stringify(p).length > FRAME_SOFT_LIMIT && guard++ < 10000) p.events.shift()
932
+ // keep the newest so the client's seqHi still advances to current.
933
+ //
934
+ // C-RG1 (2026-07-10): STAMP what we dropped. This used to end with "classifyReplay
935
+ // accepts the resulting seq jump; older events are covered by the DB transcript
936
+ // snapshot" — both halves were wrong. classifyReplay accepting the jump IS the bug
937
+ // (the client leapt over a hole it couldn't see, and its "load earlier" gate only
938
+ // detects a LEADING gap, never a middle one). And the tx: snapshot never covered
939
+ // this; the durable JSONL archive does. So say what we trimmed and let the client
940
+ // fetch it back. Events dropped here were about to be broadcast, so by construction
941
+ // they're in the archive — the stamp is always recoverable. Raise, never lower: the
942
+ // send site may have already stamped a chunk-cap trim below this one.
943
+ let guard = 0, dropped = 0
944
+ while (p.events.length > 1 && JSON.stringify(p).length > FRAME_SOFT_LIMIT && guard++ < 10000) { p.events.shift(); dropped++ }
945
+ if (dropped) {
946
+ const lo = firstSeq(p.events)
947
+ if (typeof lo === 'number') p.trimmedBefore = Math.max(p.trimmedBefore ?? 0, lo)
948
+ }
936
949
  }
937
950
  return p
938
951
  }
@@ -2702,17 +2715,30 @@ channel
2702
2715
  // Chunk the tail into ORDERED under-budget frames (A): the full log on a fresh reload
2703
2716
  // (empty cursor) is multi-MB and a single frame would be dropped by Realtime, freezing
2704
2717
  // the terminal. chunkReplayEvents splits it so nothing in the recent window is lost;
2705
- // classifyReplay applies the chunks in arrival order. Older history beyond the chunk
2706
- // budget is covered by the client's DB transcript snapshot.
2718
+ // classifyReplay applies the chunks in arrival order.
2719
+ //
2720
+ // C-RG1 (2026-07-10): chunkReplayEvents keeps only the newest REPLAY_MAX_CHUNKS —
2721
+ // a fat lane (measured: 1.7MB / 12 chunks) drops its OLDEST chunks. That hole used
2722
+ // to travel unannounced; the client absorbed the seq jump and could never fetch the
2723
+ // missing stretch back (its "load earlier" gate only sees a LEADING gap). Tell it.
2724
+ // `cursor` is what the client holds — an `ahead` reset replay is a full rebuild, so
2725
+ // it holds nothing (0). The floor keeps a fresh lane whose first turn lands at seq>1
2726
+ // from being stamped (the 2026-07-02 phantom-pill regression).
2727
+ const chunks = chunkReplayEvents(tail)
2728
+ const cursor = ahead ? 0 : from
2729
+ const trimmedBefore = trimmedBeforeSeq(firstSeq(chunks[0] || []), cursor, s.archiveOldestSeq)
2707
2730
  // Attach the terminal's last usage/ctx meter to the FIRST replay chunk. usage is
2708
2731
  // chrome (filtered out of `events` by both bridge + client), so without this a
2709
2732
  // (re)joiner's ctx meter is empty ("—") until the terminal's next turn. The client
2710
2733
  // applies `payload.lastUsage` in its code-replay handler (which is `to`-targeted, so
2711
2734
  // only the joiner gets it). Gated on `.ctx` — a suppressed meter (correctContext
2712
2735
  // nulled it for an unknown non-Claude model) replays nothing, never a misleading %.
2736
+ // trimmedBefore rides the FIRST (oldest) chunk only — the later chunks are contiguous
2737
+ // with it, so a stamp there would read as a second, phantom gap. Same rule as
2738
+ // lastUsage above and hasMore on history-page below.
2713
2739
  let firstChunk = true
2714
- for (const events of chunkReplayEvents(tail)) {
2715
- bcast('code-replay', { to: payload?.to ?? null, term: id, events, ...(ahead ? { reset: true } : {}), ...(firstChunk && s.lastUsage?.ctx ? { lastUsage: s.lastUsage } : {}) })
2740
+ for (const events of chunks) {
2741
+ bcast('code-replay', { to: payload?.to ?? null, term: id, events, ...(ahead ? { reset: true } : {}), ...(firstChunk && trimmedBefore != null ? { trimmedBefore } : {}), ...(firstChunk && s.lastUsage?.ctx ? { lastUsage: s.lastUsage } : {}) })
2716
2742
  firstChunk = false
2717
2743
  }
2718
2744
  }
package/event-id.mjs CHANGED
@@ -139,6 +139,39 @@ export function chunkReplayEvents(events, budget = REPLAY_BYTE_BUDGET, maxChunks
139
139
  return chunksRev.reverse().map((c) => c.reverse())
140
140
  }
141
141
 
142
+ // ── C-RG1/C-RG4 (2026-07-10): the bridge stamps what it trimmed. ──────────────
143
+ // chunkReplayEvents keeps only the newest maxChunks — on a fat lane the OLDEST chunks
144
+ // are dropped. Until now that hole was unannounced: classifyReplay accepted the seq
145
+ // jump, seqHi leapt past it, and because the client's "load earlier" gate compares its
146
+ // OLDEST loaded seq against the archive floor, a hole in the MIDDLE (client holds
147
+ // 1..50 ∪ 900..2000) never surfaced a pill and was unrecoverable — silently, forever.
148
+ //
149
+ // The bridge is the only party that knows. Return the lowest seq we are ACTUALLY
150
+ // sending when events below it were withheld AND are recoverable from the durable
151
+ // JSONL archive; null when nothing was withheld.
152
+ //
153
+ // Floor-aware on purpose. The seq counter is per-SESSION, not per-terminal, so a fresh
154
+ // lane's first turn legitimately lands at seq > 1 — stamping that would resurrect the
155
+ // 2026-07-02 phantom "load earlier" pill (a button that fetches nothing). `cursor` is
156
+ // what the client already holds (0 = nothing, and an `ahead` cursor-reset replay must
157
+ // pass 0). `archiveOldestSeq` is the durable floor (bridge.mjs entry.archiveOldestSeq).
158
+ // Anything at or below max(cursor, floor-1) is not missing: either the client has it,
159
+ // or the archive never had it and no affordance could ever fetch it.
160
+ export function trimmedBeforeSeq(sentFromSeq, cursor = 0, archiveOldestSeq = null) {
161
+ if (typeof sentFromSeq !== 'number') return null
162
+ const floor = typeof archiveOldestSeq === 'number' ? archiveOldestSeq : 1
163
+ const known = Math.max(cursor || 0, floor - 1)
164
+ return sentFromSeq > known + 1 ? sentFromSeq : null
165
+ }
166
+
167
+ // The lowest transcript seq in an event list (nulls sort out). Chrome events are
168
+ // seq-less (NO_SEQ_KINDS) and must not anchor the stamp.
169
+ export function firstSeq(events) {
170
+ if (!Array.isArray(events)) return null
171
+ for (const e of events) if (e && typeof e.seq === 'number') return e.seq
172
+ return null
173
+ }
174
+
142
175
  // Bound a SINGLE event so it can never overflow a broadcast frame on its own. The worst
143
176
  // offenders are tool_results carrying inline base64 IMAGES (a 592KB screenshot block) —
144
177
  // these can't ride a size-capped Realtime broadcast at all (live OR replay). Truncate
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.185",
3
+ "version": "0.7.186",
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": {