thinkpool-pair 0.6.5 → 0.6.7

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,84 @@ 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 'usage': { const c = evt.ctx; return c ? w(`${A.dim} ctx ${c.pct}% · ${Math.round(c.used / 1000)}k/${Math.round(c.max / 1000)}k${evt.costUsd != null ? ` · $${evt.costUsd.toFixed(3)}` : ''}${A.rst}`) : undefined }
334
+ case 'error': return w(`${A.red}✗ ${evt.message}${A.rst}`)
335
+ case 'tool_result': {
336
+ const c = evt.content
337
+ const t = (typeof c === 'string' ? c : Array.isArray(c) ? c.map((x) => x?.text || '').join(' ') : '').replace(/\s+/g, ' ').slice(0, 120)
338
+ return w(` ${evt.isError ? A.red : A.dim}⎿ ${t || (evt.isError ? 'error' : 'ok')}${A.rst}`)
339
+ }
340
+ case 'assistant':
341
+ for (const b of (evt.blocks || [])) {
342
+ if (b.type === 'text' && b.text?.trim()) w(`${A.grn}⏺${A.rst} ${b.text.trim()}`)
343
+ else if (b.type === 'thinking' && b.text?.trim()) w(`${A.dim}✶ ${b.text.trim().slice(0, 160)}${A.rst}`)
344
+ else if (b.type === 'tool_use') w(` ${A.cyan}${b.name}${A.rst}${argStr(b.input)}`)
345
+ }
346
+ return
347
+ default: return undefined
348
+ }
349
+ }
350
+
351
+ // The Phase-2 path: instead of a PTY, run Claude Code through the Agent SDK and
352
+ // relay STRUCTURED events. onEvent → broadcast `code-event` + print locally +
353
+ // persist to the host file; tool calls round-trip through the perm card; the
354
+ // rolling log replays to joiners and survives bridge restarts (session-store).
355
+ function openStructured({ id, model, resume, log }) {
318
356
  if (sessions.has(id)) return
319
- const entry = { cmd: 'claude', kind: 'structured', log: [], pending: new Map(), session: null }
357
+ const entry = { cmd: 'claude', kind: 'structured', log: Array.isArray(log) ? log.slice(-STRUCTURED_LOG_MAX) : [], pending: new Map(), session: null, recovered: false }
320
358
  sessions.set(id, entry)
359
+ if (entry.log.length) process.stderr.write(`\n ◆ restored ${entry.log.length} prior events (${id.slice(0, 8)})${resume ? ' + resuming live context' : ''}.\n`)
360
+ const persist = () => saveSession(room, id, { sessionId: entry.session?.sessionId || resume || null, log: entry.log })
321
361
  entry.session = startClaudeSession({
322
362
  cwd: process.cwd(), model, resume,
323
363
  onEvent: (evt) => {
324
- entry.log.push(evt)
325
- if (entry.log.length > STRUCTURED_LOG_MAX) entry.log.shift()
364
+ // Self-heal a stale resume — the saved SDK session expired. Reopen fresh,
365
+ // keeping the transcript (scrollback survives; live context is gone).
366
+ if (resume && !entry.recovered && evt.kind === 'error' && /No conversation found/i.test(evt.message || '')) {
367
+ entry.recovered = true
368
+ process.stderr.write(`\n ◆ saved session expired — starting fresh (transcript kept).\n`)
369
+ try { entry.session?.end() } catch { /* noop */ }
370
+ sessions.delete(id)
371
+ openStructured({ id, model, log: entry.log })
372
+ return
373
+ }
374
+ // Chrome events (mode / usage / clear) are transient state, not transcript —
375
+ // broadcast + print them, but keep them out of the persisted/replayed log.
376
+ const chrome = evt.kind === 'mode' || evt.kind === 'usage' || evt.kind === 'clear'
377
+ if (!chrome) {
378
+ entry.log.push(evt)
379
+ if (entry.log.length > STRUCTURED_LOG_MAX) entry.log.shift()
380
+ }
326
381
  bcast('code-event', { term: id, evt })
382
+ printLocal(evt)
383
+ if (!chrome) persist()
327
384
  },
328
385
  requestPermission: (req) => new Promise((resolve) => {
329
386
  entry.pending.set(req.id, resolve)
330
387
  bcast('code-perm-req', { term: id, id: req.id, toolName: req.toolName, input: req.input, risk: req.risk, plan: req.plan })
388
+ process.stderr.write(req.risk === 'plan'
389
+ ? `\n ${A.mag}◆ plan ready — approve in the room.${A.rst}\n`
390
+ : `\n ${A.yel}● permission: ${req.toolName}${argStr(req.input)} — approve in the room.${A.rst}\n`)
331
391
  }),
332
392
  })
333
393
  announce()
334
- process.stderr.write(`\n ◆ structured Claude session (${id.slice(0, 8)}) — driven from the room.\n`)
394
+ if (!entry.log.length) process.stderr.write(`\n ◆ structured Claude session (${id.slice(0, 8)}) — driven from the room.\n`)
335
395
  return entry
336
396
  }
337
397
 
@@ -463,6 +523,7 @@ channel
463
523
  if (/^\/clear\s*$/.test(text)) {
464
524
  echoYou(); s.session.sendTurn(text)
465
525
  s.log = []; bcast('code-event', { term: payload.term, evt: { kind: 'clear' } })
526
+ flushSession(room, payload.term, { sessionId: s.session?.sessionId || null, log: [] })
466
527
  return
467
528
  }
468
529
  s.session.sendTurn(text)
@@ -493,7 +554,13 @@ channel
493
554
  channel.track({ name, role: 'bridge' })
494
555
  if (attachedCmd && !terms.size && !sessions.size) {
495
556
  // Claude + structured mode → Agent SDK session; everything else → PTY.
496
- if (wantStructured(attachedCmd)) openStructured({ id: randomUUID() })
557
+ // On restart, restore the latest saved structured session for this room:
558
+ // replay its transcript + resume the live SDK context if recent enough.
559
+ if (wantStructured(attachedCmd)) {
560
+ const prev = loadLatest(room)
561
+ if (prev && (prev.log?.length || prev.sessionId)) openStructured({ id: prev.id, resume: canResume(prev) ? prev.sessionId : undefined, log: prev.log })
562
+ else openStructured({ id: randomUUID() })
563
+ }
497
564
  else openTerm({ id: randomUUID(), cmd: attachedCmd, args: attachedArgs, attached: true })
498
565
  }
499
566
  announce()
@@ -91,6 +91,7 @@ export function startClaudeSession({ cwd, model, resume, onEvent, requestPermiss
91
91
  let q = null // the live Query — control requests (interrupt /
92
92
  // setPermissionMode) route through it once streaming.
93
93
  let mode = 'default' // mirrors Claude Code's ⇧⇥ cycle
94
+ const alwaysAllow = new Set() // tool:risk signatures the user chose "don't ask again" for
94
95
 
95
96
  const emit = (evt) => { try { onEvent?.(evt) } catch { /* never let a consumer throw into the loop */ } }
96
97
 
@@ -120,15 +121,21 @@ export function startClaudeSession({ cwd, model, resume, onEvent, requestPermiss
120
121
  return { continue: true, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: 'The user chose "keep planning" in the ThinkPool room. Do not exit plan mode — keep refining the plan, then call ExitPlanMode again when ready.' } }
121
122
  }
122
123
  const risk = classifyRisk(toolName, toolInput)
124
+ // "Don't ask again" is keyed by tool + risk tier, so allowing medium Bash
125
+ // never silently allows a future destructive one (high always re-asks).
126
+ const sig = `${toolName}:${risk}`
123
127
  const auto =
124
128
  risk === 'low' ||
125
129
  mode === 'bypassPermissions' ||
126
- (mode === 'acceptEdits' && WRITE_TOOLS.has(toolName) && risk !== 'high')
130
+ (mode === 'acceptEdits' && WRITE_TOOLS.has(toolName) && risk !== 'high') ||
131
+ alwaysAllow.has(sig)
127
132
  let decision = 'allow'
128
133
  if (!auto) {
129
134
  try {
130
135
  decision = await requestPermission?.({ id: randomUUID(), toolName, input: toolInput, risk }) ?? 'allow'
131
136
  } catch { decision = 'deny' } // a broken permission path must fail safe (deny)
137
+ // "Allow & don't ask again" — remember the signature, then allow.
138
+ if (decision === 'always') { alwaysAllow.add(sig); decision = 'allow' }
132
139
  }
133
140
  // On deny, permissionDecisionReason IS what the model receives as the
134
141
  // tool error — make it a real instruction, not an opaque tag.
@@ -178,6 +185,17 @@ export function startClaudeSession({ cwd, model, resume, onEvent, requestPermiss
178
185
  case 'result':
179
186
  if (m.session_id) sessionId = m.session_id
180
187
  emit({ kind: 'result', subtype: m.subtype, sessionId, costUsd: m.total_cost_usd, usage: m.usage, numTurns: m.num_turns })
188
+ // Surface a usage/context meter (chrome, not a transcript line). The
189
+ // context window % comes from the control request; cost is cumulative.
190
+ ;(async () => {
191
+ let ctx = null
192
+ try {
193
+ const c = await q?.getContextUsage?.()
194
+ if (c) ctx = { used: c.totalTokens, max: c.maxTokens, pct: Math.round(c.percentage), model: c.model }
195
+ } catch { /* control req may be unavailable */ }
196
+ const u = m.usage || {}
197
+ emit({ kind: 'usage', costUsd: m.total_cost_usd ?? null, tokens: (u.input_tokens || 0) + (u.output_tokens || 0), ctx })
198
+ })()
181
199
  break
182
200
  default:
183
201
  break
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.6.5",
3
+ "version": "0.6.7",
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
+ }