thinkpool-pair 0.7.90 → 0.7.92

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
@@ -41,7 +41,7 @@ import { z } from 'zod'
41
41
  import { startClaudeSession } from './claude-session.mjs'
42
42
  import { formatPeek, PEEK, siblingsOf, resolveSibling, crossPostDecision, CROSSPOST, spawnDecision } from './cross-terminal.mjs'
43
43
  import { turnInFlight } from './update-gate.mjs'
44
- import { saveSession, flushSession, deleteSession, loadAll, canResume, loadPtyId, savePtyId, loadNames, saveNames } from './session-store.mjs'
44
+ import { saveSession, flushSession, deleteSession, loadAll, canResume, loadPtyId, savePtyId, loadNames, saveNames, appendDurableEvents, seedDurableEvents, readDurablePage } from './session-store.mjs'
45
45
  import { stampEvent, makeSeqCounter, maxSeq, seqable, capReplayEvents, chunkReplayEvents, boundEventForBroadcast, inlineImageBlocks } from './event-id.mjs'
46
46
  import { makeThrottledTrack } from './presence.mjs'
47
47
 
@@ -866,6 +866,12 @@ function pushLog(entry, evt) {
866
866
  // hole the client's applied sequence. See event-id.mjs NO_SEQ_KINDS.
867
867
  if (entry.seq && seqable(evt)) evt.seq = entry.seq.next()
868
868
  entry.log.push(evt)
869
+ // Durable, UNBOUNDED archive (powers "load earlier history"): append the transcript
870
+ // event before the in-memory cap evicts it. Only seqable kinds — chrome/thinking noise
871
+ // is skipped. entry.id/entry.room are stamped in openStructured; a PTY entry (no id)
872
+ // is a no-op. This is the single place every new event passes through, so nothing is
873
+ // missed and reloaded events (which don't ride pushLog) never double-append.
874
+ if (entry.id && seqable(evt)) appendDurableEvents(entry.room, entry.id, [evt])
869
875
  if (entry.log.length > STRUCTURED_LOG_MAX) entry.log.shift()
870
876
  return evt
871
877
  }
@@ -901,6 +907,13 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy }) {
901
907
  // an old seq, which is what keeps the between-turns restart dup-free (see
902
908
  // event-id.mjs + src/pages/code/seqDedup.js). pushLog() is the single stamp point.
903
909
  entry.seq = makeSeqCounter(maxSeq(entry.log))
910
+ // Identity for the durable archive — pushLog appends every new transcript event to
911
+ // <room>/<id>.events.jsonl keyed off these. Seed the archive once from the restored
912
+ // (≤2000) log so the retained window is immediately pageable; no-op if it already
913
+ // exists, so this never double-writes across restarts.
914
+ entry.id = id
915
+ entry.room = room
916
+ seedDurableEvents(room, id, entry.log.filter((e) => seqable(e)))
904
917
  sessions.set(id, entry)
905
918
  // Close an interrupted turn so a restored terminal comes back IDLE-with-history
906
919
  // instead of stuck "thinking" (see restoredTurnOpen). Idempotent: the closer is
@@ -1337,6 +1350,22 @@ channel
1337
1350
  }
1338
1351
  announce()
1339
1352
  })
1353
+ // "Load earlier history": serve an OLDER page from the durable JSONL archive (the
1354
+ // events the in-memory 2000-cap evicted). beforeSeq anchors the page at the client's
1355
+ // lowest loaded transcript seq; we return the slice just before it, chunked under the
1356
+ // same byte budget as replay. This is the backward cursor — it never touches seqHi
1357
+ // (the forward live/replay cursor); the client cid-dedups + reorders on the prepend.
1358
+ .on('broadcast', { event: 'history-page-request' }, ({ payload }) => {
1359
+ const id = payload?.term
1360
+ if (!id) return
1361
+ const { events, hasMore } = readDurablePage(room, id, Number(payload?.beforeSeq) || null, 200)
1362
+ const to = payload?.to ?? null
1363
+ if (!events.length) { bcast('history-page', { to, term: id, events: [], hasMore: false }); return }
1364
+ const chunks = chunkReplayEvents(events.map((e) => boundEventForBroadcast(e)))
1365
+ // hasMore rides only the FIRST (oldest) chunk so the client sets the floor flag once
1366
+ // from the true page boundary; later chunks are just more of the same page.
1367
+ chunks.forEach((evs, i) => bcast('history-page', { to, term: id, events: evs, hasMore: i === 0 ? hasMore : true }))
1368
+ })
1340
1369
  .on('broadcast', { event: 'file-put' }, ({ payload }) => {
1341
1370
  if (!payload?.id || !payload?.url) return
1342
1371
  // Multi-bridge rooms: targeted like term-open; untargeted = solo case.
package/event-id.mjs CHANGED
@@ -98,10 +98,20 @@ export function capReplayEvents(events, budget = REPLAY_BYTE_BUDGET) {
98
98
  // no event in the recent window is lost (capReplayEvents truncates; this doesn't). Each
99
99
  // returned chunk is broadcast as its own `code-replay` — seqDedup.classifyReplay applies
100
100
  // them in order (each chunk is contiguous and seq > seqHi), so the client rebuilds the
101
- // whole window across frames. We keep the MOST RECENT `maxChunks * budget` bytes (older
102
- // history is the DB transcript snapshot's job) so a giant log doesn't fan out 30+ frames.
101
+ // whole window across frames. We keep the MOST RECENT `maxChunks * budget` bytes.
102
+ //
103
+ // 6 → 14 (2026-06-29): 6 chunks = 900KB, but a real session's full log routinely
104
+ // exceeds that (measured: a forge thread = 1.0MB / 7 chunks, a maxed 2000-event
105
+ // lane = 1.7MB / 12 chunks), so the OLDEST turns — incl. the user's first prompt —
106
+ // were dropped on replay and never reached the client ("not seeing full history of
107
+ // terminal N", Max 2026-06-29). The DB transcript snapshot can't cover the gap: it's
108
+ // capped at 200 tx events AND the client can only persist what it received, so the
109
+ // head only ever lived in this in-memory log. The bridge already hard-caps the log at
110
+ // STRUCTURED_LOG_MAX (2000) events ≈ 2.1MB worst case, so 14 chunks (2.1MB) sends the
111
+ // WHOLE retained log on a fresh (cursor-0) replay — chunkReplayEvents still only emits
112
+ // as many chunks as the log needs, so small sessions are unaffected.
103
113
  // Returns an array of event-arrays, oldest chunk first; [] when there's nothing to send.
104
- export const REPLAY_MAX_CHUNKS = 6
114
+ export const REPLAY_MAX_CHUNKS = 14
105
115
  export function chunkReplayEvents(events, budget = REPLAY_BYTE_BUDGET, maxChunks = REPLAY_MAX_CHUNKS) {
106
116
  if (!Array.isArray(events) || events.length === 0) return []
107
117
  // Walk newest→oldest accumulating into the current chunk; start a new chunk when the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.90",
3
+ "version": "0.7.92",
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
@@ -60,6 +60,59 @@ function prune(room) {
60
60
  } catch { /* noop */ }
61
61
  }
62
62
 
63
+ // ── Durable append-only event log (unbounded history; the <id>.json snapshot is
64
+ // still capped at STRUCTURED_LOG_MAX for fast restart, this is the full archive). ──
65
+ // One JSONL per terminal: every transcript event appended once, O(1), never rewritten.
66
+ // Powers "load earlier history" paging without holding the whole log in memory.
67
+ const eventsLog = (room, id) => path.join(dir(room), `${id}.events.jsonl`)
68
+
69
+ // Append events to the durable log. Caller passes only the events it wants kept
70
+ // (transcript kinds) — chrome/thinking noise is filtered upstream. Sync append keeps
71
+ // strict on-disk order; writes are tiny so the blocking cost is negligible.
72
+ export function appendDurableEvents(room, id, events) {
73
+ if (!room || !id || !Array.isArray(events) || !events.length) return
74
+ try {
75
+ ensureDir(room)
76
+ fs.appendFileSync(eventsLog(room, id), events.map((e) => JSON.stringify(e)).join('\n') + '\n')
77
+ } catch { /* noop */ }
78
+ }
79
+
80
+ // One-time seed: if the durable log doesn't exist yet (a terminal that predates this
81
+ // feature, or a fresh restart before any new event), write the loaded snapshot as the
82
+ // base so the retained window is immediately pageable. No-op once the file exists, so
83
+ // reloaded events never double-append (only genuinely-new events ride pushLog → append).
84
+ export function seedDurableEvents(room, id, events) {
85
+ if (!room || !id || !Array.isArray(events) || !events.length) return
86
+ try {
87
+ ensureDir(room)
88
+ const f = eventsLog(room, id)
89
+ if (fs.existsSync(f)) return
90
+ fs.writeFileSync(f, events.map((e) => JSON.stringify(e)).join('\n') + '\n')
91
+ } catch { /* noop */ }
92
+ }
93
+
94
+ // Read a backward page: events with transcript seq < beforeSeq (newest `limit` of
95
+ // them, no-seq chrome interleaved by file order preserved). Returns { events, hasMore }.
96
+ // beforeSeq null → the newest `limit` events (the tail). hasMore = there is older still.
97
+ export function readDurablePage(room, id, beforeSeq, limit = 200) {
98
+ try {
99
+ const f = eventsLog(room, id)
100
+ if (!fs.existsSync(f)) return { events: [], hasMore: false }
101
+ const all = []
102
+ for (const line of fs.readFileSync(f, 'utf8').split('\n')) {
103
+ if (!line) continue
104
+ try { all.push(JSON.parse(line)) } catch { /* skip a torn line */ }
105
+ }
106
+ let bound = all.length
107
+ if (beforeSeq != null) {
108
+ const i = all.findIndex((e) => e && e.seq != null && e.seq >= beforeSeq)
109
+ if (i >= 0) bound = i
110
+ }
111
+ const start = Math.max(0, bound - limit)
112
+ return { events: all.slice(start, bound), hasMore: start > 0 }
113
+ } catch { return { events: [], hasMore: false } }
114
+ }
115
+
63
116
  const timers = new Map()
64
117
  function write(room, id, data) {
65
118
  try { ensureDir(room); fs.writeFileSync(path.join(dir(room), `${id}.json`), JSON.stringify({ ...data, id, savedAt: Date.now() })); prune(room) } catch { /* noop */ }