thinkpool-pair 0.7.126 → 0.7.128
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 +115 -1
- package/claude-session.mjs +42 -0
- package/package.json +2 -1
- package/sdk-smoke.mjs +60 -0
package/bridge.mjs
CHANGED
|
@@ -34,11 +34,12 @@ 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'
|
|
40
41
|
import { z } from 'zod'
|
|
41
|
-
import { startClaudeSession } from './claude-session.mjs'
|
|
42
|
+
import { startClaudeSession, oneShotSummary } from './claude-session.mjs'
|
|
42
43
|
import { FLOW_CONDUCTOR_PROMPT, FLOW_LANE_PROMPT, buildConductorEnv, assembleCrossWaveContext, buildLanePrompt } from './flow-conductor.mjs'
|
|
43
44
|
import { normalizePlanOutput } from './flow-task-graph.mjs' // FL-B1 — validate the conductor's submit_flow_plan task-graph
|
|
44
45
|
// S1 (context-offload) — durable digest store. mark_flow_done digests a closed slice in;
|
|
@@ -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,
|
|
@@ -2156,6 +2205,71 @@ channel
|
|
|
2156
2205
|
saveNames(room, termNames)
|
|
2157
2206
|
announce()
|
|
2158
2207
|
})
|
|
2208
|
+
// Session Info panel (2026-07-02) — a cheap one-shot recap on demand. NAMES live
|
|
2209
|
+
// terminals with doing/done status; aggregates CLOSED terminals into one line
|
|
2210
|
+
// without naming them (Max's spec). Runs on the room's own subscription auth via
|
|
2211
|
+
// oneShotSummary (cheap haiku → provider-default fallback). Never auto — only on
|
|
2212
|
+
// the panel button. The web caches the result in React state (no re-charge on
|
|
2213
|
+
// re-open within a page load).
|
|
2214
|
+
.on('broadcast', { event: 'code-summarize' }, async ({ payload }) => {
|
|
2215
|
+
const by = payload?.by || null
|
|
2216
|
+
bcast('code-summary', { status: 'generating', by })
|
|
2217
|
+
try {
|
|
2218
|
+
// Pull assistant/user text out of a transcript-event array, keep the tail.
|
|
2219
|
+
const logDigest = (events, max = 1200) => {
|
|
2220
|
+
const parts = []
|
|
2221
|
+
for (const e of (events || [])) {
|
|
2222
|
+
if (e?.kind === 'assistant') { for (const b of (e.blocks || [])) if (b?.type === 'text' && b.text) parts.push(b.text) }
|
|
2223
|
+
else if (e?.kind === 'you' && e.text) parts.push(`user: ${e.text}`)
|
|
2224
|
+
}
|
|
2225
|
+
return parts.join('\n').replace(/\n{3,}/g, '\n\n').slice(-max)
|
|
2226
|
+
}
|
|
2227
|
+
// LIVE structured terminals — named, doing/done, with a recent digest.
|
|
2228
|
+
const live = []
|
|
2229
|
+
let env = null, cwd = null
|
|
2230
|
+
for (const [id, e] of sessions) {
|
|
2231
|
+
if (e.kind !== 'structured') continue
|
|
2232
|
+
live.push({ name: termNames[id] || 'Terminal', status: e.session?.turnActive ? 'working' : 'done', digest: logDigest(e.log, 1100) })
|
|
2233
|
+
if (!env) { env = { ...process.env }; cwd = e.cwd || process.cwd() }
|
|
2234
|
+
}
|
|
2235
|
+
// CLOSED terminals — named but not live. Aggregate only; cap the read to bound cost.
|
|
2236
|
+
const liveIdSet = new Set(sessions.keys())
|
|
2237
|
+
const closedIds = Object.keys(termNames).filter(id => !liveIdSet.has(id))
|
|
2238
|
+
const closedDigests = []
|
|
2239
|
+
for (const id of closedIds.slice(0, 4)) {
|
|
2240
|
+
try {
|
|
2241
|
+
const page = readDurablePage(room, id, Number.MAX_SAFE_INTEGER, 30)
|
|
2242
|
+
const evs = Array.isArray(page) ? page : (Array.isArray(page?.events) ? page.events : [])
|
|
2243
|
+
const d = logDigest(evs, 450)
|
|
2244
|
+
if (d) closedDigests.push(d)
|
|
2245
|
+
} catch { /* no retained detail for this closed lane */ }
|
|
2246
|
+
}
|
|
2247
|
+
if (!live.length && !closedIds.length) { bcast('code-summary', { status: 'error', error: 'Nothing to summarize yet.', by }); return }
|
|
2248
|
+
|
|
2249
|
+
const liveBlock = live.length
|
|
2250
|
+
? live.map(t => `LIVE TERMINAL "${t.name}" [${t.status}]:\n${t.digest || '(no recent activity)'}`).join('\n\n')
|
|
2251
|
+
: '(no live terminals)'
|
|
2252
|
+
const closedBlock = closedIds.length
|
|
2253
|
+
? `\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)}`
|
|
2254
|
+
: ''
|
|
2255
|
+
|
|
2256
|
+
const prompt = `Write a concise recap of this multi-terminal coding session for someone glancing at it. Rules:
|
|
2257
|
+
- NAME each LIVE terminal and say whether it is working or done, and briefly what it is doing/did.
|
|
2258
|
+
- Do NOT name the CLOSED terminals individually — fold them into ONE aggregate clause ("plus N closed lanes that handled …").
|
|
2259
|
+
- 2 to 4 sentences total, plain prose, no markdown, no headers, no bullet lists.
|
|
2260
|
+
|
|
2261
|
+
${liveBlock}${closedBlock}
|
|
2262
|
+
|
|
2263
|
+
Recap:`
|
|
2264
|
+
|
|
2265
|
+
const res = await oneShotSummary({ prompt, env: env || process.env, cwd: cwd || process.cwd() })
|
|
2266
|
+
if (!res || !res.text) { bcast('code-summary', { status: 'error', error: 'Summary model unavailable on this provider.', by }); return }
|
|
2267
|
+
bcast('code-summary', { status: 'done', text: res.text, model: res.model, generatedAt: Date.now(), by })
|
|
2268
|
+
process.stderr.write(`\n ◆ session summary generated (${res.model}) for ${by || 'someone'}.\n`)
|
|
2269
|
+
} catch (e) {
|
|
2270
|
+
bcast('code-summary', { status: 'error', error: String(e?.message || e).slice(0, 140), by })
|
|
2271
|
+
}
|
|
2272
|
+
})
|
|
2159
2273
|
.on('broadcast', { event: 'who' }, announce)
|
|
2160
2274
|
.subscribe(status => {
|
|
2161
2275
|
if (status === 'SUBSCRIBED') {
|
package/claude-session.mjs
CHANGED
|
@@ -139,6 +139,48 @@ const TP_ROOM_REMINDER = [
|
|
|
139
139
|
'Verify before claiming done — show runtime evidence you produced, not "should work, go test it".',
|
|
140
140
|
].join(' ')
|
|
141
141
|
|
|
142
|
+
// ── One-shot summary (2026-07-02, Session Info panel) ────────────────────────
|
|
143
|
+
// A bare, cheap, cold model call — same shape as the haikuSuggest fallback: runs
|
|
144
|
+
// on the room's own subscription auth (env carries the OAuth — no API key), NO
|
|
145
|
+
// settingSources / MCP / tools, maxTurns 1. Used by the bridge's code-summarize
|
|
146
|
+
// handler to write a session recap on demand. Model fallback (Max's ask): prefer a
|
|
147
|
+
// cheap haiku-class model; if the provider (GLM / OpenRouter / custom base-url) has
|
|
148
|
+
// no such model, retry with the provider default. Returns { text, model } or null.
|
|
149
|
+
export async function oneShotSummary({ prompt, env, cwd, timeoutMs = 25000 }) {
|
|
150
|
+
const attempt = async (model) => {
|
|
151
|
+
const ac = new AbortController()
|
|
152
|
+
const t = setTimeout(() => { try { ac.abort() } catch { /* noop */ } }, timeoutMs)
|
|
153
|
+
try {
|
|
154
|
+
const hq = query({
|
|
155
|
+
prompt,
|
|
156
|
+
options: {
|
|
157
|
+
...(model ? { model } : {}),
|
|
158
|
+
...(cwd ? { cwd } : {}),
|
|
159
|
+
env,
|
|
160
|
+
maxTurns: 1,
|
|
161
|
+
permissionMode: 'bypassPermissions',
|
|
162
|
+
settingSources: [],
|
|
163
|
+
strictMcpConfig: true,
|
|
164
|
+
mcpServers: {},
|
|
165
|
+
abortController: ac,
|
|
166
|
+
},
|
|
167
|
+
})
|
|
168
|
+
let out = ''
|
|
169
|
+
for await (const mm of hq) {
|
|
170
|
+
if (mm.type === 'assistant') for (const b of (mm.message?.content || [])) if (b.type === 'text') out += b.text
|
|
171
|
+
if (mm.type === 'result') break
|
|
172
|
+
}
|
|
173
|
+
return out.trim()
|
|
174
|
+
} finally { clearTimeout(t) }
|
|
175
|
+
}
|
|
176
|
+
// Prefer cheap haiku; fall back to the provider's default model if it's absent.
|
|
177
|
+
try { const out = await attempt('claude-haiku-4-5'); if (out) return { text: out, model: 'claude-haiku-4-5' } }
|
|
178
|
+
catch { /* provider has no haiku — fall through to default */ }
|
|
179
|
+
try { const out = await attempt(undefined); if (out) return { text: out, model: 'default' } }
|
|
180
|
+
catch { /* provider default also failed */ }
|
|
181
|
+
return null
|
|
182
|
+
}
|
|
183
|
+
|
|
142
184
|
export function startClaudeSession({ cwd, model, resume, env, mode: initialMode = 'default', onEvent, requestPermission, mcpServers, crossPostGate, crossRoomPostGate, rolePrompt, blockSubagents = false, onSubmitPlan = null, onLaneDone = null, onReviewVerdict = null, reviewGate = null }) {
|
|
143
185
|
const ac = new AbortController()
|
|
144
186
|
let input = makeInputStream() // `let`: auto-restart swaps in a fresh stream
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "thinkpool-pair",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.128",
|
|
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
|
+
}
|