thinkpool-pair 0.6.5 → 0.6.6

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
@@ -35,6 +35,7 @@ import readline from 'node:readline'
35
35
  import { randomUUID } from 'node:crypto'
36
36
  import { createClient } from '@supabase/supabase-js'
37
37
  import { startClaudeSession } from './claude-session.mjs'
38
+ import { saveSession, flushSession, loadLatest, canResume } from './session-store.mjs'
38
39
 
39
40
  // Public client creds (the same anon values the web app ships — safe to embed).
40
41
  // Override with TP_SUPABASE_URL / TP_SUPABASE_ANON if you ever need to.
@@ -313,25 +314,78 @@ function openTerm({ id, cmd, args = [], attached = false, cols, rows }) {
313
314
  // SDK and relay STRUCTURED events. onEvent → broadcast `code-event`;
314
315
  // every tool call round-trips through `code-perm-req`/`code-perm` (the
315
316
  // risk-tiered permission card). A rolling event log replays to joiners.
316
- const STRUCTURED_LOG_MAX = 400
317
- function openStructured({ id, model, resume }) {
317
+ const STRUCTURED_LOG_MAX = 2000
318
+
319
+ // ── host-local pretty printer — whoever runs the bridge sees the live session
320
+ // in their own terminal too, not only the web reader. Minimal ANSI; mirrors the
321
+ // structured reader's shapes (you / assistant / tool / result / pool / note).
322
+ const A = { dim: '\x1b[2m', rst: '\x1b[0m', cyan: '\x1b[36m', mag: '\x1b[35m', grn: '\x1b[32m', yel: '\x1b[33m', red: '\x1b[31m' }
323
+ const argStr = (i) => { const v = i && (i.command || i.file_path || i.path || i.pattern || i.url || i.query || i.description); return v ? ' ' + String(v).replace(/\s+/g, ' ').slice(0, 100) : '' }
324
+ function printLocal(evt) {
325
+ const w = (s) => process.stderr.write(s + '\n')
326
+ switch (evt.kind) {
327
+ case 'you': return w(`${A.cyan}❯ ${evt.text}${A.rst}${evt.by ? ` ${A.dim}(${evt.by})${A.rst}` : ''}`)
328
+ case 'chat': return w(`${A.dim}${evt.by}: ${evt.text}${A.rst}`)
329
+ case 'whisper': return w(`${A.dim}// ${evt.by}: ${evt.text}${A.rst}`)
330
+ case 'pool': return w(evt.pending ? `${A.cyan}◌ pool synthesizing…${A.rst}` : `${A.cyan}◆ pool → ${evt.text}${A.rst}`)
331
+ case 'note': return w(`${A.dim}— ${evt.text} —${A.rst}`)
332
+ case 'clear': return w(`${A.dim}— context cleared —${A.rst}`)
333
+ case 'error': return w(`${A.red}✗ ${evt.message}${A.rst}`)
334
+ case 'tool_result': {
335
+ const c = evt.content
336
+ const t = (typeof c === 'string' ? c : Array.isArray(c) ? c.map((x) => x?.text || '').join(' ') : '').replace(/\s+/g, ' ').slice(0, 120)
337
+ return w(` ${evt.isError ? A.red : A.dim}⎿ ${t || (evt.isError ? 'error' : 'ok')}${A.rst}`)
338
+ }
339
+ case 'assistant':
340
+ for (const b of (evt.blocks || [])) {
341
+ if (b.type === 'text' && b.text?.trim()) w(`${A.grn}⏺${A.rst} ${b.text.trim()}`)
342
+ else if (b.type === 'thinking' && b.text?.trim()) w(`${A.dim}✶ ${b.text.trim().slice(0, 160)}${A.rst}`)
343
+ else if (b.type === 'tool_use') w(` ${A.cyan}${b.name}${A.rst}${argStr(b.input)}`)
344
+ }
345
+ return
346
+ default: return undefined
347
+ }
348
+ }
349
+
350
+ // The Phase-2 path: instead of a PTY, run Claude Code through the Agent SDK and
351
+ // relay STRUCTURED events. onEvent → broadcast `code-event` + print locally +
352
+ // persist to the host file; tool calls round-trip through the perm card; the
353
+ // rolling log replays to joiners and survives bridge restarts (session-store).
354
+ function openStructured({ id, model, resume, log }) {
318
355
  if (sessions.has(id)) return
319
- const entry = { cmd: 'claude', kind: 'structured', log: [], pending: new Map(), session: null }
356
+ const entry = { cmd: 'claude', kind: 'structured', log: Array.isArray(log) ? log.slice(-STRUCTURED_LOG_MAX) : [], pending: new Map(), session: null, recovered: false }
320
357
  sessions.set(id, entry)
358
+ if (entry.log.length) process.stderr.write(`\n ◆ restored ${entry.log.length} prior events (${id.slice(0, 8)})${resume ? ' + resuming live context' : ''}.\n`)
359
+ const persist = () => saveSession(room, id, { sessionId: entry.session?.sessionId || resume || null, log: entry.log })
321
360
  entry.session = startClaudeSession({
322
361
  cwd: process.cwd(), model, resume,
323
362
  onEvent: (evt) => {
363
+ // Self-heal a stale resume — the saved SDK session expired. Reopen fresh,
364
+ // keeping the transcript (scrollback survives; live context is gone).
365
+ if (resume && !entry.recovered && evt.kind === 'error' && /No conversation found/i.test(evt.message || '')) {
366
+ entry.recovered = true
367
+ process.stderr.write(`\n ◆ saved session expired — starting fresh (transcript kept).\n`)
368
+ try { entry.session?.end() } catch { /* noop */ }
369
+ sessions.delete(id)
370
+ openStructured({ id, model, log: entry.log })
371
+ return
372
+ }
324
373
  entry.log.push(evt)
325
374
  if (entry.log.length > STRUCTURED_LOG_MAX) entry.log.shift()
326
375
  bcast('code-event', { term: id, evt })
376
+ printLocal(evt)
377
+ persist()
327
378
  },
328
379
  requestPermission: (req) => new Promise((resolve) => {
329
380
  entry.pending.set(req.id, resolve)
330
381
  bcast('code-perm-req', { term: id, id: req.id, toolName: req.toolName, input: req.input, risk: req.risk, plan: req.plan })
382
+ process.stderr.write(req.risk === 'plan'
383
+ ? `\n ${A.mag}◆ plan ready — approve in the room.${A.rst}\n`
384
+ : `\n ${A.yel}● permission: ${req.toolName}${argStr(req.input)} — approve in the room.${A.rst}\n`)
331
385
  }),
332
386
  })
333
387
  announce()
334
- process.stderr.write(`\n ◆ structured Claude session (${id.slice(0, 8)}) — driven from the room.\n`)
388
+ if (!entry.log.length) process.stderr.write(`\n ◆ structured Claude session (${id.slice(0, 8)}) — driven from the room.\n`)
335
389
  return entry
336
390
  }
337
391
 
@@ -463,6 +517,7 @@ channel
463
517
  if (/^\/clear\s*$/.test(text)) {
464
518
  echoYou(); s.session.sendTurn(text)
465
519
  s.log = []; bcast('code-event', { term: payload.term, evt: { kind: 'clear' } })
520
+ flushSession(room, payload.term, { sessionId: s.session?.sessionId || null, log: [] })
466
521
  return
467
522
  }
468
523
  s.session.sendTurn(text)
@@ -493,7 +548,13 @@ channel
493
548
  channel.track({ name, role: 'bridge' })
494
549
  if (attachedCmd && !terms.size && !sessions.size) {
495
550
  // Claude + structured mode → Agent SDK session; everything else → PTY.
496
- if (wantStructured(attachedCmd)) openStructured({ id: randomUUID() })
551
+ // On restart, restore the latest saved structured session for this room:
552
+ // replay its transcript + resume the live SDK context if recent enough.
553
+ if (wantStructured(attachedCmd)) {
554
+ const prev = loadLatest(room)
555
+ if (prev && (prev.log?.length || prev.sessionId)) openStructured({ id: prev.id, resume: canResume(prev) ? prev.sessionId : undefined, log: prev.log })
556
+ else openStructured({ id: randomUUID() })
557
+ }
497
558
  else openTerm({ id: randomUUID(), cmd: attachedCmd, args: attachedArgs, attached: true })
498
559
  }
499
560
  announce()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.6.5",
3
+ "version": "0.6.6",
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": {
@@ -9,6 +9,7 @@
9
9
  "files": [
10
10
  "bridge.mjs",
11
11
  "claude-session.mjs",
12
+ "session-store.mjs",
12
13
  "README.md"
13
14
  ],
14
15
  "scripts": {
@@ -0,0 +1,57 @@
1
+ /* ─────────────────────────────────────────────────────────────
2
+ session-store.mjs — durable scrollback for structured Claude
3
+ sessions, "file now" tier (no DB). Each structured session's
4
+ event log + its SDK sessionId are written to a per-room file on
5
+ the host (~/.thinkpool-pair/<ROOM>/<id>.json), debounced. On a
6
+ bridge restart the latest session is reloaded: the transcript
7
+ replays immediately, and (if recent) the SDK session resumes so
8
+ the live context survives too. A DB-backed tier comes later.
9
+ ───────────────────────────────────────────────────────────── */
10
+
11
+ import os from 'node:os'
12
+ import fs from 'node:fs'
13
+ import path from 'node:path'
14
+
15
+ const ROOT = path.join(os.homedir(), '.thinkpool-pair')
16
+ const dir = (room) => path.join(ROOT, room || 'default')
17
+ // Only resume the live SDK context for recent sessions — an expired session id
18
+ // fails ("No conversation found"); past this window we restore transcript only.
19
+ const RESUME_MAX_AGE_MS = 12 * 60 * 60 * 1000
20
+ const KEEP = 8 // newest N session files per room
21
+
22
+ function ensureDir(room) { try { fs.mkdirSync(dir(room), { recursive: true }) } catch { /* noop */ } }
23
+ function listRecs(room) {
24
+ try {
25
+ return fs.readdirSync(dir(room)).filter((f) => f.endsWith('.json'))
26
+ .map((f) => { try { return JSON.parse(fs.readFileSync(path.join(dir(room), f), 'utf8')) } catch { return null } })
27
+ .filter(Boolean)
28
+ } catch { return [] }
29
+ }
30
+ function prune(room) {
31
+ try {
32
+ 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 } })
34
+ .sort((a, b) => b.m - a.m)
35
+ files.slice(KEEP).forEach((x) => { try { fs.unlinkSync(x.p) } catch { /* noop */ } })
36
+ } catch { /* noop */ }
37
+ }
38
+
39
+ const timers = new Map()
40
+ function write(room, id, data) {
41
+ try { ensureDir(room); fs.writeFileSync(path.join(dir(room), `${id}.json`), JSON.stringify({ ...data, id, savedAt: Date.now() })); prune(room) } catch { /* noop */ }
42
+ }
43
+ // Debounced — events arrive in bursts; one write per ~1.5s is plenty.
44
+ export function saveSession(room, id, data) {
45
+ clearTimeout(timers.get(id))
46
+ timers.set(id, setTimeout(() => write(room, id, data), 1500))
47
+ }
48
+ export function flushSession(room, id, data) { clearTimeout(timers.get(id)); write(room, id, data) }
49
+
50
+ // Most-recently-saved structured session for the room (drives attached restore).
51
+ export function loadLatest(room) {
52
+ const recs = listRecs(room).sort((a, b) => (b.savedAt || 0) - (a.savedAt || 0))
53
+ return recs[0] || null
54
+ }
55
+ export function canResume(rec) {
56
+ return !!(rec && rec.sessionId && rec.savedAt && (Date.now() - rec.savedAt) < RESUME_MAX_AGE_MS)
57
+ }