thinkpool-pair 0.6.4 → 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
 
@@ -444,17 +498,30 @@ channel
444
498
  })
445
499
  .on('broadcast', { event: 'code-turn' }, ({ payload }) => {
446
500
  const s = payload?.term && sessions.get(payload.term)
447
- if (s && payload.text != null) {
448
- s.session.sendTurn(payload.text)
449
- // Echo the turn so BOTH readers (and late joiners, via the log) show who
450
- // said what — UNLESS silent (an @pool synthesis, which renders as its own
451
- // 'pool' line). The sender rendered it optimistically; partner gets this.
452
- if (!payload.silent) {
453
- const evt = { kind: 'you', text: payload.text, cid: payload.cid, by: payload.by }
454
- s.log.push(evt); if (s.log.length > STRUCTURED_LOG_MAX) s.log.shift()
455
- bcast('code-event', { term: payload.term, evt })
456
- }
501
+ if (!s || payload.text == null) return
502
+ const text = String(payload.text)
503
+ // Echo the turn so BOTH readers (and late joiners, via the log) show who
504
+ // said what — UNLESS silent (an @pool synthesis, which renders its own line).
505
+ const echoYou = () => {
506
+ if (payload.silent) return
507
+ const evt = { kind: 'you', text, cid: payload.cid, by: payload.by }
508
+ s.log.push(evt); if (s.log.length > STRUCTURED_LOG_MAX) s.log.shift()
509
+ bcast('code-event', { term: payload.term, evt })
510
+ }
511
+ // Most slash commands (/compact, /clear, …) are processed by the SDK when
512
+ // sent as a turn. Two need special handling:
513
+ // • /model <name> is disabled headless → route to the setModel control.
514
+ // • /clear also wipes the on-screen transcript (parity with the CLI).
515
+ const mm = text.match(/^\/model\b\s*(\S+)?/)
516
+ if (mm) { echoYou(); if (mm[1]) s.session.setModel(mm[1]); else s.session.listModels(); return }
517
+ if (/^\/clear\s*$/.test(text)) {
518
+ echoYou(); s.session.sendTurn(text)
519
+ s.log = []; bcast('code-event', { term: payload.term, evt: { kind: 'clear' } })
520
+ flushSession(room, payload.term, { sessionId: s.session?.sessionId || null, log: [] })
521
+ return
457
522
  }
523
+ s.session.sendTurn(text)
524
+ echoYou()
458
525
  })
459
526
  .on('broadcast', { event: 'code-perm' }, ({ payload }) => {
460
527
  const s = payload?.term && sessions.get(payload.term)
@@ -481,7 +548,13 @@ channel
481
548
  channel.track({ name, role: 'bridge' })
482
549
  if (attachedCmd && !terms.size && !sessions.size) {
483
550
  // Claude + structured mode → Agent SDK session; everything else → PTY.
484
- 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
+ }
485
558
  else openTerm({ id: randomUUID(), cmd: attachedCmd, args: attachedArgs, attached: true })
486
559
  }
487
560
  announce()
@@ -199,6 +199,19 @@ export function startClaudeSession({ cwd, model, resume, onEvent, requestPermiss
199
199
  try { await q?.setPermissionMode?.(m) } catch { /* only valid mid-stream */ }
200
200
  emit({ kind: 'mode', mode })
201
201
  },
202
+ // /model is disabled in headless SDK — switch via the setModel control
203
+ // instead, and echo a note line so both drivers see the change.
204
+ async setModel(m) {
205
+ try { await q?.setModel?.(m) } catch { /* may reject unknown model */ }
206
+ emit({ kind: 'note', text: `model → ${m}` })
207
+ },
208
+ async listModels() {
209
+ try {
210
+ const ms = await q?.supportedModels?.()
211
+ const names = (ms || []).map((x) => x.model || x.id || x.name).filter(Boolean)
212
+ emit({ kind: 'note', text: names.length ? `models: ${names.join(', ')}` : 'no model list available' })
213
+ } catch { emit({ kind: 'note', text: 'usage: /model <name>' }) }
214
+ },
202
215
  // Graceful interrupt (Esc / Stop) — stops the current turn but keeps the
203
216
  // session alive for the next one. ac.abort() is teardown only (end()).
204
217
  async abort() { try { await q?.interrupt?.() } catch { /* noop */ } },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.6.4",
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
+ }