thinkpool-pair 0.7.292 → 0.7.294

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/README.md CHANGED
@@ -144,12 +144,25 @@ npx thinkpool-pair@latest <ROOM> -- claude # structured Claude, no TTY
144
144
  - `pty-in` — keystrokes/prompts from the web → written to that terminal's PTY.
145
145
  - `term-open` / `term-close` / `term-exit` — web-driven terminal lifecycle.
146
146
  - `replay-request` / `pty-replay` — each terminal keeps a rolling ~120 KB
147
- scrollback buffer; joining/reloading clients get it replayed, so the room
148
- never opens blank while the bridge is up. **Nothing is stored server-side** —
149
- history lives exactly as long as the bridge runs.
147
+ scrollback buffer for live recovery. Thinkpool also stores the cleaned reader
148
+ transcript and room events so members can reopen the room. It does not store
149
+ a copy of the repository or the raw PTY byte stream.
150
150
  - `resize` — web viewport size → headless PTYs only (the attached terminal
151
151
  follows your own TTY).
152
152
 
153
+ ## Inspect the privacy boundary
154
+
155
+ Run a local report before pairing a repository:
156
+
157
+ ```bash
158
+ npx thinkpool-pair@latest privacy-report
159
+ ```
160
+
161
+ It lists the project directories configured for this bridge, the local records
162
+ present under `~/.thinkpool-pair`, known outbound service domains, what
163
+ Thinkpool stores remotely, and the limits of the report. It never prints a
164
+ provider key, refresh token, or bridge private key.
165
+
153
166
  ## Run any model
154
167
 
155
168
  ThinkPool Code runs **any model you choose** — not just Anthropic. The agent
package/account.mjs CHANGED
@@ -416,6 +416,7 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
416
416
  const childIdle = new Map() // room -> bool (last idle report: in the quiet window)
417
417
  const childBetween = new Map() // room -> bool (between turns now — safe to restart per Contract #1)
418
418
  const childPeer = new Map() // room -> bool (a web client is watching this room)
419
+ const readyRooms = new Set() // child subscribed + restored + announced usable room state
419
420
  // Thinkpool Ensemble cross-ROOM router (Tier 1): the supervisor is the only process
420
421
  // that knows every room on this machine, so it routes a child's read_session into the
421
422
  // target room's child and relays the reply back. roomNames feeds list_sessions; pairPending
@@ -562,7 +563,7 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
562
563
  // to spawn against — re-read each announce so an add/remove reflects immediately.
563
564
  // Both are additive; older clients ignore them (feature: multi-provider BYOK, slice 1).
564
565
  const PROVIDER_PUBKEY = publicKeyB64()
565
- const pushPresence = () => trackPresence({ name: machine, bridgeId: BRIDGE_ID, version: VERSION, rooms: [...children.keys()], refused: [...refused].map(([code, reason]) => ({ code, reason })), service: runsAsService(), providerPubKey: PROVIDER_PUBKEY, providers: announceProviders(), agents: installedAgentCommands().map((cmd) => ({ cmd })), ts: Date.now() })
566
+ const pushPresence = () => trackPresence({ name: machine, bridgeId: BRIDGE_ID, version: VERSION, rooms: [...children.keys()], readyRooms: [...readyRooms], refused: [...refused].map(([code, reason]) => ({ code, reason })), service: runsAsService(), providerPubKey: PROVIDER_PUBKEY, providers: announceProviders(), agents: installedAgentCommands().map((cmd) => ({ cmd })), ts: Date.now() })
566
567
 
567
568
  // ── Provider registry — the multi-BYOK wire contract (slice 1). ─────────────
568
569
  // AUTH: the account channel `tpacct:<uid>` is created WITHOUT config.private:true,
@@ -895,12 +896,18 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
895
896
  console.log(`child_spawn sup=${SUP_ID} room=${room} pid=${child.pid} dir=${dir} had_prior=${alreadyHad}`)
896
897
 
897
898
  children.set(room, child)
899
+ readyRooms.delete(room)
898
900
  childIdle.set(room, false) // unknown until it reports → never restart a fresh child
899
901
  childBetween.set(room, false)
900
902
  childPeer.set(room, false)
901
903
  child.on('message', (m) => {
902
904
  if (!m) return
903
- if (m.t === 'idle') { childIdle.set(room, !!m.idle); childBetween.set(room, m.between != null ? !!m.between : !!m.idle); childPeer.set(room, !!m.webPeer) }
905
+ if (m.t === 'room-ready') {
906
+ readyRooms.add(room)
907
+ console.log(`child_ready sup=${SUP_ID} room=${room} pid=${child.pid}`)
908
+ pushPresence()
909
+ }
910
+ else if (m.t === 'idle') { childIdle.set(room, !!m.idle); childBetween.set(room, m.between != null ? !!m.between : !!m.idle); childPeer.set(room, !!m.webPeer) }
904
911
  else if (m.t === 'apply-update') { applyRequested = true; applyIfIdle() } // user clicked the chip
905
912
  // ── Thinkpool Ensemble cross-ROOM routing ──────────────────────────────
906
913
  // `room` here is the room that sent the message (this closure is per-child).
@@ -993,7 +1000,7 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
993
1000
  // ── supervisor child_exit logging (cascade brg-instrument) ─────────────────
994
1001
  console.log(`child_exit sup=${SUP_ID} room=${room} pid=${child?.pid || '?'} code=${code || '?'}`)
995
1002
 
996
- children.delete(room); childIdle.delete(room); childBetween.delete(room); childPeer.delete(room); roomNames.delete(room); roomPartner.delete(room) // re-served on the next tick
1003
+ children.delete(room); readyRooms.delete(room); childIdle.delete(room); childBetween.delete(room); childPeer.delete(room); roomNames.delete(room); roomPartner.delete(room) // re-served on the next tick
997
1004
  // Fail any in-flight cross-room peeks/posts this dead room had ASKED (bus-origin
998
1005
  // entries have askerRoom null and just time out on their own).
999
1006
  for (const [reqId, w] of pairPending) { if (w?.askerRoom === room) pairPending.delete(reqId) }
package/bridge.mjs CHANGED
@@ -226,6 +226,12 @@ const pickAgent = (installed) => new Promise((resolve) => {
226
226
 
227
227
  const argv = process.argv.slice(2)
228
228
 
229
+ if (argv[0] === 'privacy-report') {
230
+ const { runPrivacyReport } = await import('./privacy-report.mjs')
231
+ runPrivacyReport()
232
+ process.exit(0)
233
+ }
234
+
229
235
  // Boot-persistent service install (cross-platform: launchd / systemd / Windows
230
236
  // Startup). Subcommand form: `thinkpool-pair install-service <ROOM> [-- <cmd>]`.
231
237
  if (argv[0] === 'install-service' || argv[0] === 'uninstall-service') {
@@ -1111,7 +1117,7 @@ const announce = () => {
1111
1117
  // on a custom provider without the owner's account-channel registry.
1112
1118
  const provNames = providerNameMap()
1113
1119
  const rev = ++announceRev
1114
- return bcast('bridge', {
1120
+ return bcastAwait('bridge', {
1115
1121
  v: 2, name, bridge_id: BRIDGE_ID, started_at: BRIDGE_STARTED_AT, rev, repo: repoLabel, branch: readBranch(),
1116
1122
  // sdkWarn: the auto-pulled agent SDK failed its boot compatibility smoke test —
1117
1123
  // surfaced so the room can show a banner (turns may misbehave; pin a good SDK).
@@ -4453,7 +4459,14 @@ channel
4453
4459
  openTerm({ id: ptyId, cmd: startCmd, args: attachedArgs, attached: !autoAgent })
4454
4460
  }
4455
4461
  }
4456
- announce()
4462
+ await announce()
4463
+ // Account presence must distinguish a spawned child from a usable room.
4464
+ // Signal only after realtime subscribed, durable sessions restored, and the
4465
+ // first authoritative roster was announced. The dashboard keeps the bridge
4466
+ // in "Restarting" until every pre-restart room reaches this boundary.
4467
+ if (process.send && process.env.THINKPOOL_PAIR_ACCOUNT_CHILD === '1') {
4468
+ try { process.send({ t: 'room-ready', room, bridgeId: BRIDGE_ID }) } catch { /* supervisor exited */ }
4469
+ }
4457
4470
  process.stderr.write(headless
4458
4471
  ? `\n ◆ thinkpool — relaying room ${room} (headless). Open terminals from the web UI.\n\n`
4459
4472
  : `\n ◆ thinkpool — sharing "${attachedCmd}"${continuing ? ' (continuing your latest session)' : ''} into room ${room}. Open the web UI and you're both in.\n\n`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.292",
3
+ "version": "0.7.294",
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": {
@@ -13,6 +13,7 @@
13
13
  "sdk-admission.mjs",
14
14
  "sdk-admission.mjs",
15
15
  "launcher.mjs",
16
+ "privacy-report.mjs",
16
17
  "byok-detect.mjs",
17
18
  "context-windows.mjs",
18
19
  "claude-session.mjs",
@@ -0,0 +1,108 @@
1
+ import fs from 'node:fs'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+
5
+ const cleanHost = (value) => {
6
+ try { return new URL(value).host } catch { return null }
7
+ }
8
+
9
+ const readJson = (file, fallback) => {
10
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')) } catch { return fallback }
11
+ }
12
+
13
+ const displayPath = (value, home) => {
14
+ const resolved = path.resolve(String(value || ''))
15
+ return resolved === home ? '~' : resolved.startsWith(`${home}${path.sep}`) ? `~${resolved.slice(home.length)}` : resolved
16
+ }
17
+
18
+ export function buildPrivacyReport({
19
+ home = os.homedir(),
20
+ cwd = process.cwd(),
21
+ env = process.env,
22
+ exists = fs.existsSync,
23
+ read = readJson,
24
+ } = {}) {
25
+ const configDir = path.join(home, '.thinkpool-pair')
26
+ const dirs = read(path.join(configDir, 'dirs.json'), {}) || {}
27
+ const served = read(path.join(configDir, 'served.json'), {}) || {}
28
+ const providers = read(path.join(configDir, 'providers.json'), []) || []
29
+ const legacyProvider = read(path.join(configDir, 'provider.json'), null)
30
+ let defaultDir = ''
31
+ try { defaultDir = fs.readFileSync(path.join(configDir, 'default-dir'), 'utf8').trim() } catch { /* not configured */ }
32
+
33
+ const projectDirs = new Set([cwd, defaultDir, ...Object.values(dirs), ...Object.values(served)].filter(Boolean).map((p) => displayPath(p, home)))
34
+ const providerHosts = new Set([
35
+ ...providers.map((provider) => cleanHost(provider?.baseUrl)),
36
+ cleanHost(legacyProvider?.baseUrl),
37
+ ].filter(Boolean))
38
+ const supabaseHost = cleanHost(env.TP_SUPABASE_URL || 'https://daytvtakmlixpfbbqzjd.supabase.co')
39
+ const webHost = cleanHost(env.TP_WEB_BASE || 'https://thinkpool.io')
40
+
41
+ const localRecords = [
42
+ ['Saved login', 'auth.json', exists(path.join(configDir, 'auth.json')), 'refresh token and account identity'],
43
+ ['Provider registry', 'providers.json', exists(path.join(configDir, 'providers.json')), 'provider endpoint, model, and API key'],
44
+ ['Bridge keypair', 'bridge-key.json', exists(path.join(configDir, 'bridge-key.json')), 'private key used to open browser-sealed provider keys'],
45
+ ['Room directories', 'dirs.json / served.json', exists(path.join(configDir, 'dirs.json')) || exists(path.join(configDir, 'served.json')), 'room-to-project directory mappings'],
46
+ ['Bridge logs', 'update.log and service logs', exists(path.join(configDir, 'update.log')), 'update and background-service diagnostics'],
47
+ ]
48
+
49
+ return {
50
+ generatedAt: new Date().toISOString(),
51
+ configDir: displayPath(configDir, home),
52
+ projectDirs: [...projectDirs].sort(),
53
+ localRecords,
54
+ outbound: [
55
+ [webHost, 'Thinkpool configuration, linking, version checks, and product APIs'],
56
+ [supabaseHost, 'authentication, room database, storage, and realtime transport'],
57
+ ['registry.npmjs.org', 'package installation and updates initiated with npm or npx'],
58
+ ...[...providerHosts].sort().map((host) => [host, 'AI provider selected on this bridge']),
59
+ ].filter(([host]) => host),
60
+ remote: [
61
+ ['Thinkpool cloud receives', 'account/profile data; room membership and metadata; room chat; cleaned terminal output; terminal and room events; attachments; operational request metadata'],
62
+ ['Thinkpool cloud does not receive as a repository', 'a crawled copy of the project directory or repository file tree'],
63
+ ['Readable provider key', 'stored locally by the bridge; Thinkpool relays only a browser-sealed envelope'],
64
+ ['AI prompts and code context', 'sent by the local coding agent to the selected AI provider; content depends on that runtime and the task'],
65
+ ],
66
+ limits: [
67
+ 'Coding-agent runtimes and commands can contact domains beyond the bridge list above.',
68
+ 'Thinkpool room transcripts are readable by Thinkpool infrastructure today; they are not end-to-end encrypted.',
69
+ 'This report describes configured access. It is not a packet capture or a guarantee that a third-party agent obeys the same boundary.',
70
+ ],
71
+ }
72
+ }
73
+
74
+ export function formatPrivacyReport(report) {
75
+ const yn = (value) => value ? 'present' : 'not present'
76
+ const lines = [
77
+ '',
78
+ ' THINKPOOL PRIVACY REPORT',
79
+ ` generated ${report.generatedAt}`,
80
+ '',
81
+ ' LOCAL ACCESS',
82
+ ` Bridge configuration: ${report.configDir}`,
83
+ ...report.projectDirs.map((dir) => ` Project directory: ${dir}`),
84
+ '',
85
+ ' LOCAL RECORDS',
86
+ ...report.localRecords.map(([label, file, present, contains]) => ` ${label}: ${yn(present)} (${file})\n contains: ${contains}`),
87
+ '',
88
+ ' OUTBOUND DOMAINS',
89
+ ...report.outbound.map(([host, reason]) => ` ${host}\n ${reason}`),
90
+ '',
91
+ ' REMOTE DATA',
92
+ ...report.remote.map(([label, detail]) => ` ${label}:\n ${detail}`),
93
+ '',
94
+ ' IMPORTANT LIMITS',
95
+ ...report.limits.map((line) => ` - ${line}`),
96
+ '',
97
+ ' Full data map: https://thinkpool.io/data',
98
+ '',
99
+ ]
100
+ return lines.join('\n')
101
+ }
102
+
103
+ export function runPrivacyReport(options) {
104
+ const report = buildPrivacyReport(options)
105
+ process.stdout.write(formatPrivacyReport(report))
106
+ return report
107
+ }
108
+