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 +66 -5
- package/package.json +2 -1
- package/session-store.mjs +57 -0
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 =
|
|
317
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
+
}
|