thinkpool-pair 0.7.127 → 0.7.129
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 +68 -7
- package/package.json +2 -1
- package/sdk-smoke.mjs +60 -0
- package/session-store.mjs +11 -0
package/bridge.mjs
CHANGED
|
@@ -34,6 +34,7 @@ import path from 'node:path'
|
|
|
34
34
|
import readline from 'node:readline'
|
|
35
35
|
import { spawn, execSync, execFileSync } from 'node:child_process'
|
|
36
36
|
import { fileURLToPath } from 'node:url'
|
|
37
|
+
import { createRequire } from 'node:module'
|
|
37
38
|
import { randomUUID } from 'node:crypto'
|
|
38
39
|
import { createClient } from '@supabase/supabase-js'
|
|
39
40
|
import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk'
|
|
@@ -82,7 +83,7 @@ const flowRedispatch = new Map()
|
|
|
82
83
|
const flowBudgets = new Map()
|
|
83
84
|
import { formatPeek, PEEK, siblingsOf, resolveSibling, crossPostDecision, CROSSPOST, spawnDecision, CROSSROOM, formatPairRoster, crossRoomPostDecision } from './cross-terminal.mjs'
|
|
84
85
|
import { turnInFlight } from './update-gate.mjs'
|
|
85
|
-
import { saveSession, flushSession, deleteSession, loadAll, canResume, loadPtyId, savePtyId, loadNames, saveNames, appendDurableEvents, seedDurableEvents, readDurablePage, readDurableOldestSeq } from './session-store.mjs'
|
|
86
|
+
import { saveSession, flushSession, deleteSession, loadAll, canResume, loadPtyId, savePtyId, loadNames, saveNames, appendDurableEvents, seedDurableEvents, readDurablePage, readDurableOldestSeq, loadSummary, saveSummary } from './session-store.mjs'
|
|
86
87
|
import { stampEvent, makeSeqCounter, maxSeq, seqable, capReplayEvents, chunkReplayEvents, boundEventForBroadcast, inlineImageBlocks } from './event-id.mjs'
|
|
87
88
|
import { makeThrottledTrack } from './presence.mjs'
|
|
88
89
|
|
|
@@ -303,6 +304,51 @@ if ((!argv[0] || argv[0] === 'setup') && process.stdin.isTTY) {
|
|
|
303
304
|
process.exit(0)
|
|
304
305
|
}
|
|
305
306
|
|
|
307
|
+
// ── agent-SDK compatibility gate ────────────────────────────────────────────
|
|
308
|
+
// The SDK tracks a caret (^0.3.x), so a restart can auto-pull a NEW SDK, and its
|
|
309
|
+
// interrupt/turn API has broken between 0.3.x minors before. Smoke-test the API
|
|
310
|
+
// surface (offline, no tokens) the FIRST time a given SDK version is seen, so a
|
|
311
|
+
// bad one is caught + surfaced loudly (stderr + the announce banner) instead of
|
|
312
|
+
// silently breaking turns. sdkStatus feeds the announce. Fully guarded — a check
|
|
313
|
+
// error never blocks serving.
|
|
314
|
+
const SMOKE_PATH = path.join(path.dirname(fileURLToPath(import.meta.url)), 'sdk-smoke.mjs')
|
|
315
|
+
let sdkStatus = { ok: null, version: 'unknown', reason: '' }
|
|
316
|
+
function currentSdkVersion() {
|
|
317
|
+
try {
|
|
318
|
+
const req = createRequire(import.meta.url)
|
|
319
|
+
let d = path.dirname(req.resolve('@anthropic-ai/claude-agent-sdk'))
|
|
320
|
+
for (let i = 0; i < 8; i++) {
|
|
321
|
+
try { const p = JSON.parse(fs.readFileSync(path.join(d, 'package.json'), 'utf8')); if (p.name === '@anthropic-ai/claude-agent-sdk') return p.version } catch { /* keep walking */ }
|
|
322
|
+
const up = path.dirname(d); if (up === d) break; d = up
|
|
323
|
+
}
|
|
324
|
+
} catch { /* unresolved */ }
|
|
325
|
+
return 'unknown'
|
|
326
|
+
}
|
|
327
|
+
function runSmoke() {
|
|
328
|
+
try { return execFileSync(process.execPath, [SMOKE_PATH], { encoding: 'utf8', timeout: 22000, stdio: ['ignore', 'pipe', 'ignore'] }).trim() }
|
|
329
|
+
catch (e) { return (e.stdout || '').toString().trim() || `SMOKE:FAIL:${currentSdkVersion()}:${e.code === 'ETIMEDOUT' ? 'timeout' : (e.message || 'run error').slice(0, 80)}` }
|
|
330
|
+
}
|
|
331
|
+
if (argv[0] === 'verify-sdk') {
|
|
332
|
+
const out = runSmoke(); console.log(out); process.exit(/^SMOKE:PASS/.test(out) ? 0 : 1)
|
|
333
|
+
}
|
|
334
|
+
function checkSdkCompat() {
|
|
335
|
+
const dir = path.join(os.homedir(), '.thinkpool-pair'); const okFile = path.join(dir, 'sdk-ok')
|
|
336
|
+
const version = currentSdkVersion(); sdkStatus.version = version
|
|
337
|
+
let known = ''; try { known = fs.readFileSync(okFile, 'utf8').trim() } catch { /* none */ }
|
|
338
|
+
if (known && known === version) { sdkStatus.ok = true; return } // this exact SDK already verified
|
|
339
|
+
const m = runSmoke().match(/^SMOKE:(PASS|FAIL):([^:\n]*)(?::(.*))?$/m)
|
|
340
|
+
if (m && m[1] === 'PASS') {
|
|
341
|
+
sdkStatus = { ok: true, version: m[2] || version, reason: '' }
|
|
342
|
+
try { fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(okFile, sdkStatus.version) } catch { /* noop */ }
|
|
343
|
+
process.stderr.write(`\n ◆ agent SDK v${sdkStatus.version} passed the compatibility smoke test.\n`)
|
|
344
|
+
} else {
|
|
345
|
+
sdkStatus = { ok: false, version: (m && m[2]) || version, reason: (m && m[3]) || 'unknown' }
|
|
346
|
+
try { fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(path.join(dir, 'sdk-bad'), `${sdkStatus.version}: ${sdkStatus.reason}`) } catch { /* noop */ }
|
|
347
|
+
process.stderr.write(`\n ⚠⚠ agent SDK v${sdkStatus.version} FAILED the compatibility smoke test — ${sdkStatus.reason}.\n ⚠⚠ turns may misbehave. Pin a known-good SDK + restart:\n bridge/package.json → "@anthropic-ai/claude-agent-sdk": "<good version>", then republish + Restart & update.\n`)
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
try { checkSdkCompat() } catch (e) { process.stderr.write(` ⚠ SDK smoke check skipped: ${e.message}\n`) }
|
|
351
|
+
|
|
306
352
|
if (!argv[0] || argv[0].startsWith('-')) { const { runAccount } = await import('./account.mjs'); await runAccount(SUPABASE_URL, SUPABASE_ANON) }
|
|
307
353
|
|
|
308
354
|
const room = (argv[0] || '').toUpperCase().trim()
|
|
@@ -694,6 +740,9 @@ let roomModels = null
|
|
|
694
740
|
const announce = () =>
|
|
695
741
|
bcast('bridge', {
|
|
696
742
|
v: 2, name, repo: repoLabel, branch: readBranch(),
|
|
743
|
+
// sdkWarn: the auto-pulled agent SDK failed its boot compatibility smoke test —
|
|
744
|
+
// surfaced so the room can show a banner (turns may misbehave; pin a good SDK).
|
|
745
|
+
...(sdkStatus.ok === false ? { sdkWarn: `${sdkStatus.version}: ${sdkStatus.reason}` } : {}),
|
|
697
746
|
// models: the SDK's supported-model list, so the /model picker shows the real
|
|
698
747
|
// set (incl. Fable + version labels) on every device without hardcoding.
|
|
699
748
|
models: roomModels || undefined,
|
|
@@ -2164,6 +2213,13 @@ channel
|
|
|
2164
2213
|
// re-open within a page load).
|
|
2165
2214
|
.on('broadcast', { event: 'code-summarize' }, async ({ payload }) => {
|
|
2166
2215
|
const by = payload?.by || null
|
|
2216
|
+
// Peek — any device opening the panel fetches the cached recap (cross-device +
|
|
2217
|
+
// cross-restart persistence) WITHOUT spending a model call. No cache → silent.
|
|
2218
|
+
if (payload?.peek) {
|
|
2219
|
+
const cached = loadSummary(room)
|
|
2220
|
+
if (cached && cached.text) bcast('code-summary', { status: 'done', ...cached })
|
|
2221
|
+
return
|
|
2222
|
+
}
|
|
2167
2223
|
bcast('code-summary', { status: 'generating', by })
|
|
2168
2224
|
try {
|
|
2169
2225
|
// Pull assistant/user text out of a transcript-event array, keep the tail.
|
|
@@ -2204,18 +2260,23 @@ channel
|
|
|
2204
2260
|
? `\n\nThere are also ${closedIds.length} CLOSED terminal(s). Recent excerpts (do NOT name them individually):\n${(closedDigests.join('\n---\n') || '(no retained detail)').slice(0, 1400)}`
|
|
2205
2261
|
: ''
|
|
2206
2262
|
|
|
2207
|
-
const prompt = `
|
|
2208
|
-
-
|
|
2209
|
-
-
|
|
2210
|
-
|
|
2263
|
+
const prompt = `Summarize this multi-terminal coding session as a STRUCTURED LIST. Output ONLY these lines, nothing else:
|
|
2264
|
+
- ONE line per LIVE terminal, EXACTLY: <terminal name> | <working or done> | <one clause, max 12 words, no trailing period>
|
|
2265
|
+
- If there are CLOSED terminals, ONE final line, EXACTLY: CLOSED | <count> | <one aggregate clause about what they handled — do NOT name them>
|
|
2266
|
+
Use the pipe "|" as the separator. No headers, no markdown, no bullets, no extra prose. Example:
|
|
2267
|
+
Bridge Whip | working | SDK 0.7.128, detection gates live
|
|
2268
|
+
Reddit | done | GTM strategy mapped
|
|
2269
|
+
CLOSED | 52 | flow-trigger churn and @pool integration
|
|
2211
2270
|
|
|
2212
2271
|
${liveBlock}${closedBlock}
|
|
2213
2272
|
|
|
2214
|
-
|
|
2273
|
+
Output:`
|
|
2215
2274
|
|
|
2216
2275
|
const res = await oneShotSummary({ prompt, env: env || process.env, cwd: cwd || process.cwd() })
|
|
2217
2276
|
if (!res || !res.text) { bcast('code-summary', { status: 'error', error: 'Summary model unavailable on this provider.', by }); return }
|
|
2218
|
-
|
|
2277
|
+
const summary = { text: res.text, model: res.model, generatedAt: Date.now() }
|
|
2278
|
+
try { saveSummary(room, summary) } catch { /* cache is best-effort */ }
|
|
2279
|
+
bcast('code-summary', { status: 'done', ...summary, by })
|
|
2219
2280
|
process.stderr.write(`\n ◆ session summary generated (${res.model}) for ${by || 'someone'}.\n`)
|
|
2220
2281
|
} catch (e) {
|
|
2221
2282
|
bcast('code-summary', { status: 'error', error: String(e?.message || e).slice(0, 140), by })
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "thinkpool-pair",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.129",
|
|
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": {
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
10
|
"bridge.mjs",
|
|
11
|
+
"sdk-smoke.mjs",
|
|
11
12
|
"launcher.mjs",
|
|
12
13
|
"byok-detect.mjs",
|
|
13
14
|
"claude-session.mjs",
|
package/sdk-smoke.mjs
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// sdk-smoke.mjs — agent-SDK compatibility smoke test.
|
|
2
|
+
//
|
|
3
|
+
// The bridge tracks @anthropic-ai/claude-agent-sdk on a caret (^0.3.x), so a
|
|
4
|
+
// restart can auto-pull a NEW SDK. Our own history (feedback_bridge_sdk_pin_no_bundle)
|
|
5
|
+
// is that the SDK's interrupt/turn API has broken between 0.3.x minors. This verifies
|
|
6
|
+
// the API SURFACE the bridge depends on — BEFORE the bridge commits to serving with a
|
|
7
|
+
// freshly-pulled SDK — so a bad one is caught + surfaced instead of silently breaking
|
|
8
|
+
// every turn.
|
|
9
|
+
//
|
|
10
|
+
// Deliberately CHEAP + OFFLINE: no Claude key, no network, no token spend. It only
|
|
11
|
+
// checks that the SDK imports, query() constructs, and the control methods the bridge
|
|
12
|
+
// calls (interrupt / setModel / setPermissionMode / supportedModels) exist as
|
|
13
|
+
// functions. It does NOT run a real turn (that would cost tokens + need auth). A
|
|
14
|
+
// surface break — a removed/renamed method — is the failure mode this guards; a subtle
|
|
15
|
+
// behavioural break still needs a live turn (out of scope for a boot gate).
|
|
16
|
+
//
|
|
17
|
+
// Runs as a SHORT-LIVED SUBPROCESS with a hard timeout, so a hung SDK import/construct
|
|
18
|
+
// = FAIL, never a hung bridge. One line to stdout: `SMOKE:PASS:<version>` /
|
|
19
|
+
// `SMOKE:FAIL:<version>:<reason>`. Exit 0 = pass, 1 = incompatible, 2 = timeout.
|
|
20
|
+
|
|
21
|
+
import { createRequire } from 'node:module'
|
|
22
|
+
import { readFileSync } from 'node:fs'
|
|
23
|
+
import { dirname, join } from 'node:path'
|
|
24
|
+
|
|
25
|
+
const HARD_TIMEOUT_MS = Math.max(4000, parseInt(process.env.TP_SDK_SMOKE_TIMEOUT, 10) || 12000)
|
|
26
|
+
const guard = setTimeout(() => { emit('FAIL', ver(), 'timeout importing/constructing the SDK'); process.exit(2) }, HARD_TIMEOUT_MS)
|
|
27
|
+
|
|
28
|
+
function emit(status, version, reason) {
|
|
29
|
+
try { process.stdout.write(`SMOKE:${status}:${version || 'unknown'}${reason ? ':' + reason : ''}\n`) } catch { /* noop */ }
|
|
30
|
+
}
|
|
31
|
+
// Resolve the SDK's real version from wherever node just resolved the package (the
|
|
32
|
+
// caret pick), by walking up from its entry to the package.json with the right name.
|
|
33
|
+
function ver() {
|
|
34
|
+
try {
|
|
35
|
+
const require = createRequire(import.meta.url)
|
|
36
|
+
let d = dirname(require.resolve('@anthropic-ai/claude-agent-sdk'))
|
|
37
|
+
for (let i = 0; i < 8; i++) {
|
|
38
|
+
try { const p = JSON.parse(readFileSync(join(d, 'package.json'), 'utf8')); if (p.name === '@anthropic-ai/claude-agent-sdk') return p.version } catch { /* keep walking */ }
|
|
39
|
+
const up = dirname(d); if (up === d) break; d = up
|
|
40
|
+
}
|
|
41
|
+
} catch { /* unresolved */ }
|
|
42
|
+
return 'unknown'
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const version = ver()
|
|
46
|
+
try {
|
|
47
|
+
const sdk = await import('@anthropic-ai/claude-agent-sdk')
|
|
48
|
+
if (typeof sdk.query !== 'function') { emit('FAIL', version, 'query is not a function'); clearTimeout(guard); process.exit(1) }
|
|
49
|
+
async function* noInput() { return } // ends immediately — never blocks the construct
|
|
50
|
+
const q = sdk.query({ prompt: noInput(), options: { permissionMode: 'default' } })
|
|
51
|
+
const need = ['interrupt', 'setModel', 'setPermissionMode', 'supportedModels']
|
|
52
|
+
const missing = need.filter((m) => typeof q?.[m] !== 'function')
|
|
53
|
+
try { q?.interrupt?.() } catch { /* don't leave it running */ }
|
|
54
|
+
if (missing.length) { emit('FAIL', version, `missing methods: ${missing.join('/')}`); clearTimeout(guard); process.exit(1) }
|
|
55
|
+
emit('PASS', version)
|
|
56
|
+
clearTimeout(guard); process.exit(0)
|
|
57
|
+
} catch (e) {
|
|
58
|
+
emit('FAIL', version, `import/construct threw: ${(e?.message || e).toString().slice(0, 120)}`)
|
|
59
|
+
clearTimeout(guard); process.exit(1)
|
|
60
|
+
}
|
package/session-store.mjs
CHANGED
|
@@ -191,3 +191,14 @@ export function loadNames(room) {
|
|
|
191
191
|
export function saveNames(room, names) {
|
|
192
192
|
try { ensureDir(room); fs.writeFileSync(namesFile(room), JSON.stringify(names || {})) } catch { /* noop */ }
|
|
193
193
|
}
|
|
194
|
+
|
|
195
|
+
// Session recap cache (2026-07-02, Session Info panel). The last on-demand summary,
|
|
196
|
+
// stored as a dotfile (NOT *.json → never in loadAll) so it survives a bridge restart
|
|
197
|
+
// and any device that opens the panel can fetch it (persists across devices). One per room.
|
|
198
|
+
const summaryFile = (room) => path.join(dir(room), '.summary')
|
|
199
|
+
export function loadSummary(room) {
|
|
200
|
+
try { return JSON.parse(fs.readFileSync(summaryFile(room), 'utf8')) || null } catch { return null }
|
|
201
|
+
}
|
|
202
|
+
export function saveSummary(room, summary) {
|
|
203
|
+
try { ensureDir(room); fs.writeFileSync(summaryFile(room), JSON.stringify(summary || null)) } catch { /* noop */ }
|
|
204
|
+
}
|