thinkpool-pair 0.7.91 → 0.7.93

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, readDurableOldestSeq } 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
 
@@ -579,7 +579,7 @@ const announce = () =>
579
579
  // the chosen label instead of defaulting to Normal / unnamed. `spawned` (set
580
580
  // when an agent Dispatched this lane via spawn_terminal) lets the client keep
581
581
  // it OUT of the top tab strip and show it only in the Ensemble menu.
582
- ...[...sessions.entries()].map(([id, s]) => ({ id, cmd: s.cmd, kind: 'structured', alive: true, commands: s.commands, mode: s.mode || undefined, name: termNames[id] || undefined, ...(s.spawnedBy ? { spawned: true, spawnedBy: s.spawnedBy } : {}) })),
582
+ ...[...sessions.entries()].map(([id, s]) => ({ id, cmd: s.cmd, kind: 'structured', alive: true, commands: s.commands, mode: s.mode || undefined, name: termNames[id] || undefined, ...(s.archiveOldestSeq != null ? { oldestSeq: s.archiveOldestSeq } : {}), ...(s.spawnedBy ? { spawned: true, spawnedBy: s.spawnedBy } : {}) })),
583
583
  ],
584
584
  })
585
585
 
@@ -866,6 +866,18 @@ 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)) {
875
+ appendDurableEvents(entry.room, entry.id, [evt])
876
+ // First archived event sets the floor for a fresh lane (no seed ran). Lets the
877
+ // client correctly withhold "load earlier" until eviction pushes the loaded window
878
+ // above this floor — a new lane's first turn at seq>1 is NOT older-history.
879
+ if (entry.archiveOldestSeq == null) entry.archiveOldestSeq = evt.seq
880
+ }
869
881
  if (entry.log.length > STRUCTURED_LOG_MAX) entry.log.shift()
870
882
  return evt
871
883
  }
@@ -901,6 +913,17 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy }) {
901
913
  // an old seq, which is what keeps the between-turns restart dup-free (see
902
914
  // event-id.mjs + src/pages/code/seqDedup.js). pushLog() is the single stamp point.
903
915
  entry.seq = makeSeqCounter(maxSeq(entry.log))
916
+ // Identity for the durable archive — pushLog appends every new transcript event to
917
+ // <room>/<id>.events.jsonl keyed off these. Seed the archive once from the restored
918
+ // (≤2000) log so the retained window is immediately pageable; no-op if it already
919
+ // exists, so this never double-writes across restarts.
920
+ entry.id = id
921
+ entry.room = room
922
+ seedDurableEvents(room, id, entry.log.filter((e) => seqable(e)))
923
+ // Archive floor — the lowest seq durably stored. Drives the client's "load earlier"
924
+ // offer (show only when its oldest loaded seq is BELOW this). null on a brand-new lane
925
+ // with no archive yet; pushLog stamps it from the first appended event.
926
+ entry.archiveOldestSeq = readDurableOldestSeq(room, id)
904
927
  sessions.set(id, entry)
905
928
  // Close an interrupted turn so a restored terminal comes back IDLE-with-history
906
929
  // instead of stuck "thinking" (see restoredTurnOpen). Idempotent: the closer is
@@ -1337,6 +1360,22 @@ channel
1337
1360
  }
1338
1361
  announce()
1339
1362
  })
1363
+ // "Load earlier history": serve an OLDER page from the durable JSONL archive (the
1364
+ // events the in-memory 2000-cap evicted). beforeSeq anchors the page at the client's
1365
+ // lowest loaded transcript seq; we return the slice just before it, chunked under the
1366
+ // same byte budget as replay. This is the backward cursor — it never touches seqHi
1367
+ // (the forward live/replay cursor); the client cid-dedups + reorders on the prepend.
1368
+ .on('broadcast', { event: 'history-page-request' }, ({ payload }) => {
1369
+ const id = payload?.term
1370
+ if (!id) return
1371
+ const { events, hasMore } = readDurablePage(room, id, Number(payload?.beforeSeq) || null, 200)
1372
+ const to = payload?.to ?? null
1373
+ if (!events.length) { bcast('history-page', { to, term: id, events: [], hasMore: false }); return }
1374
+ const chunks = chunkReplayEvents(events.map((e) => boundEventForBroadcast(e)))
1375
+ // hasMore rides only the FIRST (oldest) chunk so the client sets the floor flag once
1376
+ // from the true page boundary; later chunks are just more of the same page.
1377
+ chunks.forEach((evs, i) => bcast('history-page', { to, term: id, events: evs, hasMore: i === 0 ? hasMore : true }))
1378
+ })
1340
1379
  .on('broadcast', { event: 'file-put' }, ({ payload }) => {
1341
1380
  if (!payload?.id || !payload?.url) return
1342
1381
  // Multi-bridge rooms: targeted like term-open; untargeted = solo case.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.91",
3
+ "version": "0.7.93",
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,75 @@ 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
+ // The archive FLOOR: the lowest transcript seq durably stored for this terminal.
95
+ // The client compares its oldest loaded seq against this — only OFFER "load earlier"
96
+ // when there genuinely are older events in the archive (vs a fresh lane whose first
97
+ // turn happens to land at seq > 1, or an evicted-but-unarchived gap). null = no archive.
98
+ export function readDurableOldestSeq(room, id) {
99
+ try {
100
+ const f = eventsLog(room, id)
101
+ if (!fs.existsSync(f)) return null
102
+ for (const line of fs.readFileSync(f, 'utf8').split('\n')) {
103
+ if (!line) continue
104
+ try { const e = JSON.parse(line); if (e && e.seq != null) return e.seq } catch { /* skip torn line */ }
105
+ }
106
+ return null
107
+ } catch { return null }
108
+ }
109
+
110
+ // Read a backward page: events with transcript seq < beforeSeq (newest `limit` of
111
+ // them, no-seq chrome interleaved by file order preserved). Returns { events, hasMore }.
112
+ // beforeSeq null → the newest `limit` events (the tail). hasMore = there is older still.
113
+ export function readDurablePage(room, id, beforeSeq, limit = 200) {
114
+ try {
115
+ const f = eventsLog(room, id)
116
+ if (!fs.existsSync(f)) return { events: [], hasMore: false }
117
+ const all = []
118
+ for (const line of fs.readFileSync(f, 'utf8').split('\n')) {
119
+ if (!line) continue
120
+ try { all.push(JSON.parse(line)) } catch { /* skip a torn line */ }
121
+ }
122
+ let bound = all.length
123
+ if (beforeSeq != null) {
124
+ const i = all.findIndex((e) => e && e.seq != null && e.seq >= beforeSeq)
125
+ if (i >= 0) bound = i
126
+ }
127
+ const start = Math.max(0, bound - limit)
128
+ return { events: all.slice(start, bound), hasMore: start > 0 }
129
+ } catch { return { events: [], hasMore: false } }
130
+ }
131
+
63
132
  const timers = new Map()
64
133
  function write(room, id, data) {
65
134
  try { ensureDir(room); fs.writeFileSync(path.join(dir(room), `${id}.json`), JSON.stringify({ ...data, id, savedAt: Date.now() })); prune(room) } catch { /* noop */ }