thinkpool-pair 0.7.75 → 0.7.76

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 (2) hide show
  1. package/package.json +1 -1
  2. package/session-store.mjs +35 -10
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.75",
3
+ "version": "0.7.76",
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
@@ -12,14 +12,33 @@ import os from 'node:os'
12
12
  import fs from 'node:fs'
13
13
  import path from 'node:path'
14
14
 
15
- const ROOT = path.join(os.homedir(), '.thinkpool-pair')
16
- const dir = (room) => path.join(ROOT, room || 'default')
15
+ // TP_PAIR_ROOT override exists for tests + sandboxes so they never touch the real
16
+ // ~/.thinkpool-pair store. Read LIVE per call (not captured at module load) so a test
17
+ // can point each case at a fresh tempdir after importing this module. Production leaves
18
+ // it unset → the real home-dir store.
19
+ const ROOT = () => process.env.TP_PAIR_ROOT || path.join(os.homedir(), '.thinkpool-pair')
20
+ const dir = (room) => path.join(ROOT(), room || 'default')
21
+ const archiveDir = (room) => path.join(dir(room), '.archive')
17
22
  // Only resume the live SDK context for recent sessions — an expired session id
18
23
  // fails ("No conversation found"); past this window we restore transcript only.
19
24
  const RESUME_MAX_AGE_MS = 12 * 60 * 60 * 1000
20
- const KEEP = 8 // newest N session files per room
25
+ const KEEP = 8 // newest N UNNAMED session files per room (named are immune — see prune)
21
26
 
22
27
  function ensureDir(room) { try { fs.mkdirSync(dir(room), { recursive: true }) } catch { /* noop */ } }
28
+ // Move a session file into <room>/.archive/ instead of unlinking it. Archived files
29
+ // are OUTSIDE the readdirSync(dir) path loadAll/listRecs read, so a bridge restart
30
+ // never resurrects them — but they survive on disk for manual recovery. This is the
31
+ // data-loss backstop: a pruned/closed terminal's transcript is always recoverable.
32
+ // (2026-06-26: prune used to unlinkSync, permanently destroying older terminals —
33
+ // "Blightborne" was lost this way. Named terminals are now additionally immune.)
34
+ function archive(room, file) {
35
+ try {
36
+ fs.mkdirSync(archiveDir(room), { recursive: true })
37
+ const base = path.basename(file)
38
+ const dst = path.join(archiveDir(room), `${base}.${Date.now()}`)
39
+ fs.renameSync(file, dst)
40
+ } catch { /* noop */ }
41
+ }
23
42
  function listRecs(room) {
24
43
  try {
25
44
  return fs.readdirSync(dir(room)).filter((f) => f.endsWith('.json'))
@@ -29,10 +48,15 @@ function listRecs(room) {
29
48
  }
30
49
  function prune(room) {
31
50
  try {
51
+ const names = loadNames(room) // { id: label } — NAMED terminals are immune
52
+ const namedId = (p) => { try { return JSON.parse(fs.readFileSync(p, 'utf8')).id } catch { return null } }
32
53
  const files = fs.readdirSync(dir(room)).filter((f) => f.endsWith('.json'))
33
- .map((f) => { const p = path.join(dir(room), f); let m = 0; try { m = JSON.parse(fs.readFileSync(p, 'utf8')).savedAt || 0 } catch { /* noop */ } return { p, m } })
54
+ .map((f) => { const p = path.join(dir(room), f); let m = 0; try { m = JSON.parse(fs.readFileSync(p, 'utf8')).savedAt || 0 } catch { /* noop */ } return { p, f, m } })
34
55
  .sort((a, b) => b.m - a.m)
35
- files.slice(KEEP).forEach((x) => { try { fs.unlinkSync(x.p) } catch { /* noop */ } })
56
+ // Only UNNAMED sessions are eligible for prune. A named terminal is deliberate
57
+ // user intent (a project) and must never be silently destroyed by churn.
58
+ const prunable = files.filter((x) => { const id = namedId(x.p); return !id || !names[id] })
59
+ prunable.slice(KEEP).forEach((x) => archive(room, x.p))
36
60
  } catch { /* noop */ }
37
61
  }
38
62
 
@@ -46,13 +70,14 @@ export function saveSession(room, id, data) {
46
70
  timers.set(id, setTimeout(() => write(room, id, data), 1500))
47
71
  }
48
72
  export function flushSession(room, id, data) { clearTimeout(timers.get(id)); write(room, id, data) }
49
- // User-initiated close: cancel any pending debounced save and remove the on-disk
50
- // record so a later loadAll() (bridge restart) can't resurrect a closed terminal.
51
- // PTY ids never have an <id>.json (they use the .pty-id dotfile), so this no-ops
52
- // harmlessly for them.
73
+ // User-initiated close: cancel any pending debounced save and move the on-disk
74
+ // record to .archive/ so a later loadAll() (bridge restart) can't resurrect a
75
+ // closed terminal — but the transcript stays recoverable. PTY ids never have an
76
+ // <id>.json (they use the .pty-id dotfile), so this no-ops harmlessly for them.
53
77
  export function deleteSession(room, id) {
54
78
  clearTimeout(timers.get(id)); timers.delete(id)
55
- try { fs.unlinkSync(path.join(dir(room), `${id}.json`)) } catch { /* noop */ }
79
+ const p = path.join(dir(room), `${id}.json`)
80
+ try { if (fs.existsSync(p)) archive(room, p) } catch { /* noop */ }
56
81
  }
57
82
 
58
83
  // Most-recently-saved structured session for the room (drives attached restore).