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 +74 -7
- package/claude-session.mjs +19 -1
- 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,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 =
|
|
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 '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
|
-
|
|
325
|
-
|
|
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
|
-
|
|
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()
|
package/claude-session.mjs
CHANGED
|
@@ -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.
|
|
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
|
+
}
|