thinkpool-pair 0.7.127 → 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 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'
@@ -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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.127",
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
+ }