thinkpool-pair 0.7.156 → 0.7.158

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
@@ -37,6 +37,7 @@ import { fileURLToPath } from 'node:url'
37
37
  import { createRequire } from 'node:module'
38
38
  import { randomUUID } from 'node:crypto'
39
39
  import { createClient } from '@supabase/supabase-js'
40
+ import { serveDecision, amGrantee, standDownOnPeerAnnounce, standDownOnGrantChange, fetchServeRow, refusalMessage } from './serve-consent.mjs'
40
41
  import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk'
41
42
  import { z } from 'zod'
42
43
  import { startClaudeSession } from './claude-session.mjs'
@@ -506,6 +507,13 @@ if (process.stdin.isTTY && !headless && process.env.THINKPOOL_PAIR_AUTOUPDATE !=
506
507
  }
507
508
  }
508
509
  const name = process.env.TP_NAME || os.userInfo().username || 'host'
510
+ // host: this machine's short label — os.hostname() with any DNS/domain suffix
511
+ // stripped and capped ~24 chars. A /code room can be served by different bridges
512
+ // over time (Max's Mac vs Conrad's Linux box); the client uses this to show WHICH
513
+ // machine currently serves the room and attribute dormant terminals to their home
514
+ // box. Distinct from `name` (the driver's username). Announced additively —
515
+ // top-level only (one bridge per announce); older clients ignore the unknown key.
516
+ const host = (os.hostname() || 'host').split('.')[0].slice(0, 24)
509
517
 
510
518
  // Repo awareness — the room shows which project this machine is sharing.
511
519
  // Cheap reads, no subprocess: directory name + .git/HEAD.
@@ -562,7 +570,49 @@ async function refreshOwnerPlan() {
562
570
  if (rows?.[0]?.plan) ownerPlan = rows[0].plan === 'plus' ? 'plus' : 'free'
563
571
  } catch { /* DNS-flaky / offline — keep the safe default; the next token refresh retries */ }
564
572
  }
565
- refreshOwnerPlan() // fire-and-forget on boot (codeAuthToken may be set from env)
573
+ // ── Room-serve owner consent (Contract C-CODE-3) ──────────────────────────────
574
+ // A room is served by its OWNER's bridge; a non-owner may serve only with the
575
+ // owner's explicit grant (code_sessions.serve_grant_uid). Enforced HERE — before the
576
+ // realtime channel is even built — so a refused bridge never dials the room. Owner
577
+ // reclaim (a grantee yields when the owner's bridge reappears) + grant revocation are
578
+ // wired below on the EXISTING room channel / REST row — no new topic
579
+ // (feedback_no_duplicate_realtime_channel, H41). LIMITATION: enforcement is in-bridge;
580
+ // an OLDER bridge bypasses it (no wire-level lock). Spec: docs/specs/2026-07-04-room-serve-owner-consent.md.
581
+ let roomOwnerId = null // code_sessions.owner_id, learned at the gate
582
+ let myServeUid = null // this bridge's authed uid (null = anon)
583
+ let iServeAsGrantee = false // serving by grant, not as owner → the only bridge that yields
584
+ let yielding = false // set once when standing down, so reclaim + revoke don't double-fire
585
+ {
586
+ // Standalone room mode ran anon unless TP_ACCESS_TOKEN was in env; load the stored
587
+ // login so `npx thinkpool-pair <CODE>` authenticates AS the signed-in user (the
588
+ // identity the consent check needs). Account children already carry the env token.
589
+ if (!codeAuthToken) {
590
+ try { const { loadAuth } = await import('./auth-store.mjs'); codeAuthToken = loadAuth()?.access_token || null } catch { /* no store — stays anon */ }
591
+ }
592
+ try {
593
+ if (codeAuthToken) {
594
+ const { data } = await supabase.auth.getUser(codeAuthToken)
595
+ myServeUid = data?.user?.id || null
596
+ }
597
+ const { ownerId, grantUid, name } = await fetchServeRow({ supabaseUrl: SUPABASE_URL, anonKey: SUPABASE_ANON, token: codeAuthToken, room })
598
+ roomOwnerId = ownerId
599
+ const decision = serveDecision({ ownerId, grantUid, myUid: myServeUid })
600
+ if (!decision.serve) {
601
+ process.stderr.write(refusalMessage({ room, name, reason: decision.reason }))
602
+ // exit 0 — an intentional stand-down, not a crash, so a supervisor does not
603
+ // thrash-restart it (matches the machine-lock stand-down at the top of boot).
604
+ process.exit(0)
605
+ }
606
+ iServeAsGrantee = amGrantee({ ownerId, grantUid, myUid: myServeUid })
607
+ if (iServeAsGrantee) process.stderr.write(`\n ◆ serving room ${room} by the owner's grant — will yield if the owner's bridge returns or the grant is revoked.\n`)
608
+ } catch (e) {
609
+ // Fail OPEN on a network/transport error ONLY (spec: never strand a user on a
610
+ // flaky fetch). A clean "no row / not granted" is a decision above, not a throw.
611
+ process.stderr.write(`\n ◇ serve-consent check degraded (${e.message}); proceeding without it.\n`)
612
+ }
613
+ }
614
+
615
+ refreshOwnerPlan() // fire-and-forget on boot (codeAuthToken now set from env OR the stored login)
566
616
 
567
617
  // ── Pre-flight budget caps — spec docs/specs/2026-07-01-budget-caps.md (nexos steal #1).
568
618
  // The bridge calls the durable ledger RPCs as the OWNER (codeAuthToken); is_session_member
@@ -788,6 +838,17 @@ const announce = () =>
788
838
  // cwd + version: the host's working dir + thinkpool-pair version, shown in
789
839
  // the room's welcome banner. Re-sent per announce so late joiners get them.
790
840
  cwd, version: VERSION,
841
+ // host: short machine label (see const `host`) so the room shows which box
842
+ // currently serves it + attributes dormant terminals to their home machine.
843
+ // Additive top-level field; consumed by src/pages/code/room.jsx onAnnounce in
844
+ // a later lane. Older clients ignore it.
845
+ host,
846
+ // uid: the authed user id whose bridge is serving this room (owner or grantee).
847
+ // Additive field (older clients ignore it). A grantee bridge watches peer `bridge`
848
+ // announces for uid === owner_id to know the owner's bridge reclaimed the room and
849
+ // it must stand down (Contract C-CODE-3 invariant 3). No new topic — rides the
850
+ // existing announce (feedback_no_duplicate_realtime_channel).
851
+ uid: myServeUid || undefined,
791
852
  // provider: the LLM endpoint this bridge drives Claude Code through —
792
853
  // 'anthropic' (the default, the host's regular Claude login) or 'custom'
793
854
  // (any Anthropic-compatible base url: GLM/Z.ai, OpenRouter, a proxy). Shown
@@ -829,6 +890,21 @@ const announce = () =>
829
890
  ],
830
891
  })
831
892
 
893
+ // Owner reclaim (Contract C-CODE-3 invariant 3): a grantee-served bridge that sees
894
+ // the OWNER's bridge announce on this same room channel stands down — the owner's
895
+ // bridge always wins. Reuses the existing `bridge` announce (its additive `uid`) — no
896
+ // new topic (feedback_no_duplicate_realtime_channel). The owner's own bridge never
897
+ // yields (standDownOnPeerAnnounce returns false for it). Clean shutdown(0): shuttingDown
898
+ // suppresses the term-exit broadcast, so no structured terminal row is reaped
899
+ // (docs/specs/2026-07-04-structured-terminal-durability.md — a handover must not delete rows).
900
+ function onPeerBridgeAnnounce (payload) {
901
+ if (yielding) return
902
+ if (!standDownOnPeerAnnounce({ myUid: myServeUid, ownerId: roomOwnerId, peerUid: payload?.uid })) return
903
+ yielding = true
904
+ process.stderr.write(`\n ◆ owner's bridge reconnected — yielding room ${room}.\n`)
905
+ shutdown(0)
906
+ }
907
+
832
908
  // ── Thinkpool Ensemble — cross-ROOM reach (Tier 1: same-machine pair peek) ────
833
909
  // The in-room Ensemble (read_terminal over sibling terminals) expanded ACROSS the
834
910
  // account's OTHER rooms. The account supervisor (account.mjs) is the router: this
@@ -2466,6 +2542,9 @@ channel
2466
2542
  announce()
2467
2543
  })
2468
2544
  .on('broadcast', { event: 'who' }, announce)
2545
+ // Peer bridge announces (owner reclaim) — a grantee yields when the owner's bridge
2546
+ // reappears. broadcast self:false, so this only fires for OTHER bridges on the topic.
2547
+ .on('broadcast', { event: 'bridge' }, ({ payload }) => onPeerBridgeAnnounce(payload))
2469
2548
  .subscribe(status => {
2470
2549
  if (status === 'SUBSCRIBED') {
2471
2550
  realtimeHealthy = true; brokenSince = 0
@@ -2832,6 +2911,26 @@ setInterval(() => {
2832
2911
  }
2833
2912
  }, 15000).unref()
2834
2913
 
2914
+ // Grant revocation mid-serve (Contract C-CODE-3 invariant 3): a grantee re-checks the
2915
+ // row on a slow cadence — a cheap poll on the EXISTING REST endpoint, no new realtime
2916
+ // subscription (the owner-reclaim announce path above is the fast one). If the grant no
2917
+ // longer names this bridge (cleared or reassigned), stand down. Owner bridges never poll
2918
+ // (iServeAsGrantee is false for them). A flaky fetch never yields — next tick re-checks.
2919
+ if (iServeAsGrantee) {
2920
+ const RECHECK_MS = Math.max(15000, parseInt(process.env.TP_SERVE_RECHECK_MS, 10) || 30000)
2921
+ setInterval(async () => {
2922
+ if (yielding) return
2923
+ try {
2924
+ const { grantUid } = await fetchServeRow({ supabaseUrl: SUPABASE_URL, anonKey: SUPABASE_ANON, token: codeAuthToken, room })
2925
+ if (standDownOnGrantChange({ myUid: myServeUid, ownerId: roomOwnerId, grantUid })) {
2926
+ yielding = true
2927
+ process.stderr.write(`\n ◆ serve grant revoked — yielding room ${room}.\n`)
2928
+ shutdown(0)
2929
+ }
2930
+ } catch { /* transient — never yield on a flaky fetch; next tick re-checks */ }
2931
+ }, RECHECK_MS).unref()
2932
+ }
2933
+
2835
2934
  // ── auto-update (OS-service tier only) ─────────────────────────────────
2836
2935
  // launchd (KeepAlive) and systemd (Restart=always) re-run
2837
2936
  // `npx -y thinkpool-pair@latest` on every (re)start, so a clean exit here makes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.156",
3
+ "version": "0.7.158",
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": {
@@ -31,6 +31,7 @@
31
31
  "flow-redispatch.mjs",
32
32
  "flow-skill-registry.mjs",
33
33
  "serve-dir.mjs",
34
+ "serve-consent.mjs",
34
35
  "service.mjs",
35
36
  "presence.mjs",
36
37
  "account.mjs",
@@ -0,0 +1,95 @@
1
+ // Room-serve owner consent — decision core (pure) + row fetch.
2
+ //
3
+ // Spec: docs/specs/2026-07-04-room-serve-owner-consent.md (Contract C-CODE-3).
4
+ // A room is served by its OWNER's bridge; a non-owner bridge may serve only with
5
+ // the owner's explicit grant (`code_sessions.serve_grant_uid`); the owner's bridge
6
+ // always wins reclaim, the grantee stands down.
7
+ //
8
+ // This module holds ONLY pure, order-independent decisions (unit-tested in
9
+ // bridge/test-serve-consent.mjs) plus a single network helper. The wiring —
10
+ // where the gate runs, when a grantee yields — lives in bridge.mjs. Keeping the
11
+ // logic pure is the feedback_code_seed_order_independent discipline: no decision
12
+ // depends on live channel/session state, so every branch is testable off-wire.
13
+ //
14
+ // LIMITATION (documented, spec Contract C-CODE-3 invariant 1): enforcement is
15
+ // in-bridge. A bridge OLDER than this version never reads the column and bypasses
16
+ // the check — there is no server-side wire-level lock. The grant is a coordination
17
+ // signal honored by compliant bridges, not a serve token. Out of scope here.
18
+
19
+ // serveDecision — may THIS bridge serve THIS room?
20
+ // ownerId : code_sessions.owner_id (uuid | null)
21
+ // grantUid : code_sessions.serve_grant_uid (uuid | null) — the one granted non-owner
22
+ // myUid : this bridge's authed user id (uuid | null) — null when the bridge is anon
23
+ // Returns { serve, reason }:
24
+ // reason 'owner' — myUid owns the room (always serves)
25
+ // reason 'grant' — myUid is the granted non-owner
26
+ // reason 'no-identity' — bridge has no authed uid, so it can be neither owner nor
27
+ // grantee → refuse (the new model has no anon serve path)
28
+ // reason 'not-granted' — authed, but neither owner nor the current grantee → refuse
29
+ // reason 'unknown-room'— no owner_id resolved (row missing / RLS-hidden) → refuse
30
+ export function serveDecision ({ ownerId, grantUid, myUid }) {
31
+ if (!myUid) return { serve: false, reason: 'no-identity' }
32
+ if (!ownerId) return { serve: false, reason: 'unknown-room' }
33
+ if (myUid === ownerId) return { serve: true, reason: 'owner' }
34
+ if (grantUid && myUid === grantUid) return { serve: true, reason: 'grant' }
35
+ return { serve: false, reason: 'not-granted' }
36
+ }
37
+
38
+ // amGrantee — this bridge serves by grant (not as owner). Grantees are the only
39
+ // bridges that ever stand down; the owner's bridge never yields.
40
+ export function amGrantee ({ ownerId, grantUid, myUid }) {
41
+ return !!(myUid && myUid !== ownerId && grantUid && myUid === grantUid)
42
+ }
43
+
44
+ // standDownOnPeerAnnounce — a grantee-served bridge saw ANOTHER bridge announce on
45
+ // the room channel. Yield iff that peer is the OWNER's bridge (peerUid === ownerId)
46
+ // and I am not the owner. The owner's own bridge (myUid === ownerId) never yields —
47
+ // serveDecision already refused any second non-owner, so a peer that isn't the owner
48
+ // is either a stale duplicate of me or nothing to cede to.
49
+ export function standDownOnPeerAnnounce ({ myUid, ownerId, peerUid }) {
50
+ if (!myUid || !ownerId) return false
51
+ if (myUid === ownerId) return false // owner never stands down
52
+ return !!peerUid && peerUid === ownerId // owner's bridge reclaimed → yield
53
+ }
54
+
55
+ // standDownOnGrantChange — a grantee re-checked the row and the grant no longer
56
+ // names it (revoked, or reassigned to a third party). Yield. The owner never yields
57
+ // on a grant change (the grant does not govern the owner).
58
+ export function standDownOnGrantChange ({ myUid, ownerId, grantUid }) {
59
+ if (!myUid || myUid === ownerId) return false
60
+ return grantUid !== myUid
61
+ }
62
+
63
+ // fetchServeRow — read the room's consent columns with whatever identity the bridge
64
+ // holds (authed token → RLS returns the row if the user is a member; anon → empty).
65
+ // THROWS on a network/transport error so the caller can fail OPEN (never strand a
66
+ // user on a flaky fetch). A clean HTTP response that simply yields no row is NOT a
67
+ // throw — it returns { ownerId: null } and the caller refuses (unknown room).
68
+ export async function fetchServeRow ({ supabaseUrl, anonKey, token, room }) {
69
+ const headers = { apikey: anonKey }
70
+ if (token) headers.Authorization = `Bearer ${token}`
71
+ const r = await fetch(
72
+ `${supabaseUrl}/rest/v1/code_sessions?select=owner_id,serve_grant_uid,name&code=eq.${encodeURIComponent(room)}`,
73
+ { headers },
74
+ )
75
+ if (!r.ok) throw new Error(`code_sessions fetch ${r.status}`)
76
+ const rows = await r.json()
77
+ const row = Array.isArray(rows) ? rows[0] : null
78
+ return {
79
+ ownerId: row?.owner_id ?? null,
80
+ grantUid: row?.serve_grant_uid ?? null,
81
+ name: row?.name ?? null,
82
+ }
83
+ }
84
+
85
+ // refusalMessage — the human-facing stderr line when a bridge refuses to serve.
86
+ export function refusalMessage ({ room, name, reason }) {
87
+ const label = name ? `"${name}"` : '(unnamed)'
88
+ if (reason === 'no-identity') {
89
+ return `\n ◇ Refusing to serve room ${room} — this bridge is not signed in.\n` +
90
+ ` A room is served by its owner's bridge. Sign in first: npx thinkpool-pair login\n`
91
+ }
92
+ return `\n ◇ Refusing to serve room ${room} ${label} — it is owned by another person; their bridge serves it.\n` +
93
+ ` Ask them to grant your machine hosting rights in the room (bridge status card),\n` +
94
+ ` then re-run. The grant is the only way in — there is no override flag.\n`
95
+ }