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 +88 -15
- package/claude-session.mjs +13 -0
- 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
|
|
|
@@ -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
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
if (
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
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
|
-
|
|
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()
|
package/claude-session.mjs
CHANGED
|
@@ -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.
|
|
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
|
+
}
|