thinkpool-pair 0.7.186 → 0.7.187

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 CHANGED
@@ -94,7 +94,7 @@ const flowRedispatch = new Map()
94
94
  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
- import { saveSession, flushSession, deleteSession, loadAll, canResume, loadPtyId, savePtyId, loadNames, saveNames, appendDurableEvents, seedDurableEvents, readDurablePage, readDurableOldestSeq } from './session-store.mjs'
97
+ import { saveSession, flushSession, deleteSession, loadAll, canResume, loadPtyId, savePtyId, loadNames, saveNames, appendDurableEvents, seedDurableEvents, readDurablePage, readDurableOldestSeq, hasDurableArchive, servesHistoryPage } from './session-store.mjs'
98
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'
@@ -2761,6 +2761,13 @@ channel
2761
2761
  .on('broadcast', { event: 'history-page-request' }, ({ payload }) => {
2762
2762
  const id = payload?.term
2763
2763
  if (!id) return
2764
+ // Multi-bridge rooms (Max's two Macs; the account-supervisor path): only the bridge
2765
+ // that HOLDS this terminal may answer. A foreign bridge used to reply with an empty
2766
+ // page — the client reads the first empty page as "no older history", clears the gap
2767
+ // and kills the load-earlier pill, so recoverable history reads as gone. Targeting
2768
+ // (payload.host) is honoured too, matching term-open / file-put / code-open / flow-*,
2769
+ // but no client stamps it yet — ownership is what actually closes the race.
2770
+ if (!servesHistoryPage({ payloadHost: payload?.host, bridgeName: name, hasSession: sessions.has(id), hasArchive: hasDurableArchive(room, id) })) return
2764
2771
  const { events, hasMore } = readDurablePage(room, id, Number(payload?.beforeSeq) || null, 200)
2765
2772
  const to = payload?.to ?? null
2766
2773
  if (!events.length) { bcast('history-page', { to, term: id, events: [], hasMore: false }); return }
@@ -2856,7 +2863,15 @@ channel
2856
2863
  if (/^\/clear\s*$/.test(text)) {
2857
2864
  s.pendingRecap = null // /clear means forget context — drop any un-fired carry recap
2858
2865
  s.session.sendTurn(text)
2859
- s.log = []; s.seq = makeSeqCounter(0); bcast('code-event', { term: payload.term, evt: { kind: 'clear' } })
2866
+ // The seq reset is load-bearing on BOTH ends: the client's clear handler sets
2867
+ // seqHi = 0 (room.jsx), so the counter must restart with it. The durable archive
2868
+ // is append-only and keeps the pre-clear era under colliding seqs — session-store's
2869
+ // epochStart() confines every read to the newest era, so the archive never splices
2870
+ // two conversations. Drop the cached floor with it: the next pushLog re-seeds
2871
+ // archiveOldestSeq from the FIRST post-clear event (the new epoch's floor), instead
2872
+ // of leaving a stale pre-clear floor to mis-aim trimmedBeforeSeq's phantom-pill guard.
2873
+ s.log = []; s.seq = makeSeqCounter(0); s.archiveOldestSeq = null
2874
+ bcast('code-event', { term: payload.term, evt: { kind: 'clear' } })
2860
2875
  flushSession(room, payload.term, { sessionId: s.session?.sessionId || null, log: [] })
2861
2876
  ctlLine('context cleared. You can continue with these answers in mind.')
2862
2877
  return
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.186",
3
+ "version": "0.7.187",
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": {
package/session-store.mjs CHANGED
@@ -115,17 +115,75 @@ export function seedDurableEvents(room, id, events) {
115
115
  } catch { /* noop */ }
116
116
  }
117
117
 
118
- // The archive FLOOR: the lowest transcript seq durably stored for this terminal.
119
- // The client compares its oldest loaded seq against this — only OFFER "load earlier"
120
- // when there genuinely are older events in the archive (vs a fresh lane whose first
121
- // turn happens to land at seq > 1, or an evicted-but-unarchived gap). null = no archive.
118
+ // Does this bridge hold a durable archive for `id`? The capability half of the
119
+ // history-page-request guard (a foreign bridge in a multi-bridge room has no file).
120
+ export function hasDurableArchive(room, id) {
121
+ try { return !!room && !!id && fs.existsSync(eventsLog(room, id)) } catch { return false }
122
+ }
123
+
124
+ // Should THIS bridge answer a history-page-request? (pure — tested in test-history-ab.mjs)
125
+ //
126
+ // Two independent gates, both required:
127
+ // · `payloadHost` — the same targeting guard every other broadcast handler carries
128
+ // (term-open, file-put, code-open, flow-*): a targeted request is for ONE machine.
129
+ // · ownership — no client build to date STAMPS a host on this event, so targeting
130
+ // alone leaves an untargeted request answerable by every bridge in the room. The
131
+ // bridge that doesn't serve the terminal has no archive, replies
132
+ // `{ events: [], hasMore: false }`, and the client reads that first empty page as
133
+ // "no older history" — the load-earlier pill dies and recoverable history looks
134
+ // gone. Only the bridge that actually HOLDS the terminal may speak: a live session,
135
+ // or (a closed lane whose transcript is still on screen) its durable archive.
136
+ export function servesHistoryPage({ payloadHost, bridgeName, hasSession, hasArchive }) {
137
+ if (payloadHost && payloadHost !== bridgeName) return false
138
+ return !!(hasSession || hasArchive)
139
+ }
140
+
141
+ // ── Epochs: /clear resets the per-session seq counter (bridge.mjs) but the durable
142
+ // archive is append-only and keeps every era. So one file can hold seq 1..500 (pre-clear)
143
+ // followed by seq 1..30 (post-clear) — colliding keys. `findIndex(e.seq >= beforeSeq)`
144
+ // then anchors in the WRONG era and a page splices two conversations together.
145
+ //
146
+ // A seq that does not strictly increase over its predecessor is, by construction, a
147
+ // counter reset: within one bridge run the counter only climbs, and a restart reseeds it
148
+ // from maxSeq(restored log). So the last regression marks the start of the CURRENT epoch,
149
+ // and only that epoch is pageable. Everything older is a conversation the user explicitly
150
+ // cleared — surfacing it under colliding seqs would be wrong twice over.
151
+ //
152
+ // This is read-side only: it needs no migration and fixes archives already on disk
153
+ // (every user's, right now) as well as ones written from here on.
154
+ function epochStart(all) {
155
+ let start = 0, last = -Infinity
156
+ for (let i = 0; i < all.length; i++) {
157
+ const s = all[i]?.seq
158
+ if (typeof s !== 'number') continue
159
+ if (s <= last) { start = i; last = s; continue } // counter went backwards → new era
160
+ last = s
161
+ }
162
+ return start
163
+ }
164
+
165
+ function readArchive(room, id) {
166
+ const f = eventsLog(room, id)
167
+ if (!fs.existsSync(f)) return null
168
+ const all = []
169
+ for (const line of fs.readFileSync(f, 'utf8').split('\n')) {
170
+ if (!line) continue
171
+ try { all.push(JSON.parse(line)) } catch { /* skip a torn line */ }
172
+ }
173
+ return all
174
+ }
175
+
176
+ // The archive FLOOR: the lowest transcript seq durably stored for this terminal *in the
177
+ // current epoch*. The client compares its oldest loaded seq against this — only OFFER
178
+ // "load earlier" when there genuinely are older events in the archive (vs a fresh lane
179
+ // whose first turn happens to land at seq > 1, or an evicted-but-unarchived gap).
180
+ // null = no archive.
122
181
  export function readDurableOldestSeq(room, id) {
123
182
  try {
124
- const f = eventsLog(room, id)
125
- if (!fs.existsSync(f)) return null
126
- for (const line of fs.readFileSync(f, 'utf8').split('\n')) {
127
- if (!line) continue
128
- try { const e = JSON.parse(line); if (e && e.seq != null) return e.seq } catch { /* skip torn line */ }
183
+ const all = readArchive(room, id)
184
+ if (!all) return null
185
+ for (let i = epochStart(all); i < all.length; i++) {
186
+ if (all[i] && all[i].seq != null) return all[i].seq
129
187
  }
130
188
  return null
131
189
  } catch { return null }
@@ -134,22 +192,19 @@ export function readDurableOldestSeq(room, id) {
134
192
  // Read a backward page: events with transcript seq < beforeSeq (newest `limit` of
135
193
  // them, no-seq chrome interleaved by file order preserved). Returns { events, hasMore }.
136
194
  // beforeSeq null → the newest `limit` events (the tail). hasMore = there is older still.
195
+ // Never reads below the current epoch — see epochStart.
137
196
  export function readDurablePage(room, id, beforeSeq, limit = 200) {
138
197
  try {
139
- const f = eventsLog(room, id)
140
- if (!fs.existsSync(f)) return { events: [], hasMore: false }
141
- const all = []
142
- for (const line of fs.readFileSync(f, 'utf8').split('\n')) {
143
- if (!line) continue
144
- try { all.push(JSON.parse(line)) } catch { /* skip a torn line */ }
145
- }
198
+ const all = readArchive(room, id)
199
+ if (!all) return { events: [], hasMore: false }
200
+ const from = epochStart(all)
146
201
  let bound = all.length
147
202
  if (beforeSeq != null) {
148
- const i = all.findIndex((e) => e && e.seq != null && e.seq >= beforeSeq)
203
+ const i = all.findIndex((e, idx) => idx >= from && e && e.seq != null && e.seq >= beforeSeq)
149
204
  if (i >= 0) bound = i
150
205
  }
151
- const start = Math.max(0, bound - limit)
152
- return { events: all.slice(start, bound), hasMore: start > 0 }
206
+ const start = Math.max(from, bound - limit)
207
+ return { events: all.slice(start, Math.max(start, bound)), hasMore: start > from }
153
208
  } catch { return { events: [], hasMore: false } }
154
209
  }
155
210