thinkpool-pair 0.7.167 → 0.7.168

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/account.mjs CHANGED
@@ -17,7 +17,7 @@ import { saveAuth, loadAuth, loadDirs, bindDir, loadServed, rememberServed, load
17
17
  import { isSafeToRestart } from './update-gate.mjs'
18
18
  import { makeThrottledTrack } from './presence.mjs'
19
19
  import { publicKeyB64, announceProviders, listProviders, addProvider, removeProvider, unseal } from './providers.mjs'
20
- import { supervisorServes, shouldCooldownOnExit, roomInCooldown } from './serve-consent.mjs'
20
+ import { supervisorServes } from './serve-consent.mjs'
21
21
  import { resolveServeDir } from './serve-dir.mjs'
22
22
  import { pairKeyFor, pairTopic, CROSSROOM_BUS } from './cross-terminal.mjs'
23
23
 
@@ -232,13 +232,6 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
232
232
  process.stderr.write(`\n ◆ thinkpool-pair — account mode · ${email}\n New sessions auto-serve from ${_effDefault}${loadDefaultDir() ? ' (your default)' : (claudeRecentProjectDir() ? ' (your most-recent Claude Code project)' : '')}\n (change it: npx thinkpool-pair set-default-dir <dir> · one room: npx thinkpool-pair bind <code> <dir>)\n${svcHint}`)
233
233
 
234
234
  const children = new Map() // room -> child process
235
- // grantCooldown: room -> wall-clock ms deadline. A grantee child that yielded to the
236
- // owner's returning bridge (exit 75) parks its room here so discovery does NOT re-spawn a
237
- // child that would only yield again — the owner-serves/grantee-stands-by coexistence state
238
- // must not thrash-churn every 15s tick (DEFECT 2, 2026-07-05). Expires after TP_GRANT_COOLDOWN_MS;
239
- // after that discovery retries once — serving normally if the owner has since gone.
240
- const grantCooldown = new Map()
241
- const GRANT_COOLDOWN_MS = Math.max(15_000, parseInt(process.env.TP_GRANT_COOLDOWN_MS, 10) || 120_000)
242
235
  const childIdle = new Map() // room -> bool (last idle report: in the quiet window)
243
236
  const childBetween = new Map() // room -> bool (between turns now — safe to restart per Contract #1)
244
237
  const childPeer = new Map() // room -> bool (a web client is watching this room)
@@ -602,17 +595,12 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
602
595
  let rooms = []
603
596
  const me = session.user.id
604
597
  try {
605
- // Discovery (Contract C-CODE-2, evolved owner-only → owner-OR-granted): a host
606
- // bridges the sessions it OWNS *plus* any it has been explicitly granted to serve
607
- // (code_sessions.serve_grant_uid = me — the owner-consent grant, #206/#208). RLS
608
- // returns owned OR participant rooms, and a grantee is ALWAYS a participant
609
- // (set_serve_grant requires it), so a granted row is visible to this login. Without
610
- // a filter the supervisor would auto-serve rooms you merely JOINED as a guest —
611
- // spawning your machine into another owner's session that already runs its own
612
- // bridge; the .or keeps un-granted joins out while letting a consented grant IN.
613
- // The web watches tpacct:<session-owner>; a granted bridge serves on the owner's
614
- // behalf (the grant IS the consent), so it is wanted duplication, not stray.
615
- const { data } = await sb.from('code_sessions').select('code,name,last_active_at,owner_id,serve_grant_uid,participants').or(`owner_id.eq.${me},serve_grant_uid.eq.${me}`).order('last_active_at', { ascending: false }).limit(20)
598
+ // Discovery (Contract C-CODE-2, owner-only since 2026-07-06): a host bridges ONLY
599
+ // the sessions it OWNS. RLS returns owned OR participant rooms; the owner filter
600
+ // keeps rooms you merely JOINED as a guest out of auto-serve — your machine never
601
+ // spawns into another owner's session (cross-person serving removed; pairing is
602
+ // read-only peer peek, not hosting). The web watches tpacct:<session-owner>.
603
+ const { data } = await sb.from('code_sessions').select('code,name,last_active_at,owner_id,participants').eq('owner_id', me).order('last_active_at', { ascending: false }).limit(20)
616
604
  rooms = data || []
617
605
  // Prune refused entries for rooms we can no longer serve — owned OR granted (only
618
606
  // on a SUCCESSFUL fetch, so a transient query error never wipes the set).
@@ -624,15 +612,8 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
624
612
  for (const r of rooms) {
625
613
  const room = r.code
626
614
  if (!room || children.has(room)) { if (room) refused.delete(room); continue } // served → not refused
627
- // Grant-reclaim cooldown (DEFECT 2): this room's grantee child recently yielded to the
628
- // owner's bridge (exit 75). Skip re-serving it until the window elapses so we don't
629
- // thrash-respawn a child that will only yield again. refused already reads 'owner-serves'
630
- // (set on that exit below), so presence stays honest while we stand by.
631
- const cdUntil = grantCooldown.get(room)
632
- if (roomInCooldown(cdUntil, Date.now())) continue
633
- if (cdUntil) grantCooldown.delete(room) // window elapsed — allow one fresh attempt (owner may have gone)
634
- // Defense-in-depth behind the .or above: serve iff we OWN the room or hold its grant.
635
- if (!supervisorServes({ ownerId: r.owner_id, grantUid: r.serve_grant_uid, myUid: me })) continue
615
+ // Defense-in-depth behind the owner-only query above: serve iff we OWN the room.
616
+ if (!supervisorServes({ ownerId: r.owner_id, myUid: me })) continue
636
617
  // Resolve the serve dir: explicit bind > last-served memory > launch dir. REFUSE
637
618
  // to auto-serve an unbound room from $HOME — that strips the agent's resume context
638
619
  // (cwd-keyed SDK sessions) and shows your home folder as the repo (FVIHV1DE).
@@ -760,15 +741,6 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
760
741
  console.log(`child_exit sup=${SUP_ID} room=${room} pid=${child?.pid || '?'} code=${code || '?'}`)
761
742
 
762
743
  children.delete(room); childIdle.delete(room); childBetween.delete(room); childPeer.delete(room); roomNames.delete(room); roomPartner.delete(room) // re-served on the next tick
763
- // Owner-reclaim SENTINEL (75): the grantee child stood down because the owner's bridge
764
- // returned. Park the room on a cooldown so discovery doesn't respawn it into another
765
- // immediate yield (DEFECT 2), and mark refused 'owner-serves' so presence is honest.
766
- // A revoke-yield exits 0 (discovery drops a revoked room anyway); owner-owned rooms
767
- // never yield, so neither cools down. `code` is undefined on the 'error' path — fine.
768
- if (shouldCooldownOnExit(code)) {
769
- grantCooldown.set(room, Date.now() + GRANT_COOLDOWN_MS)
770
- refused.set(room, 'owner-serves')
771
- }
772
744
  // Fail any in-flight cross-room peeks/posts this dead room had ASKED (bus-origin
773
745
  // entries have askerRoom null and just time out on their own).
774
746
  for (const [reqId, w] of pairPending) { if (w?.askerRoom === room) pairPending.delete(reqId) }
package/bridge.mjs CHANGED
@@ -37,7 +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, gateFailAction, GRANT_RECLAIM_EXIT } from './serve-consent.mjs'
40
+ import { serveDecision, fetchServeRow, refusalMessage, gateFailAction } from './serve-consent.mjs'
41
41
  import { reapTerminalRow as _reapTerminalRow } from './reap-terminal.mjs'
42
42
  // resolveProviderEnv(id) → {ANTHROPIC_BASE_URL,ANTHROPIC_AUTH_TOKEN,ANTHROPIC_MODEL} for a
43
43
  // registered custom provider, or null for the built-in/unknown (leave the default env intact).
@@ -665,18 +665,16 @@ const isRoomParticipant = async (jwt) => {
665
665
  return Array.isArray(rows) && rows.length > 0
666
666
  } catch { return false }
667
667
  }
668
- // ── Room-serve owner consent (Contract C-CODE-3) ──────────────────────────────
669
- // A room is served by its OWNER's bridge; a non-owner may serve only with the
670
- // owner's explicit grant (code_sessions.serve_grant_uid). Enforced HERE — before the
671
- // realtime channel is even built — so a refused bridge never dials the room. Owner
672
- // reclaim (a grantee yields when the owner's bridge reappears) + grant revocation are
673
- // wired below on the EXISTING room channel / REST row — no new topic
674
- // (feedback_no_duplicate_realtime_channel, H41). LIMITATION: enforcement is in-bridge;
675
- // an OLDER bridge bypasses it (no wire-level lock). Spec: docs/specs/2026-07-04-room-serve-owner-consent.md.
676
- let roomOwnerId = null // code_sessions.owner_id, learned at the gate
668
+ // ── Room-serve owner consent (Contract C-CODE-3, owner-only since 2026-07-06) ──
669
+ // A room is served by its OWNER's bridge ONLY — no cross-person grantee path
670
+ // (removed 2026-07-06; see docs/specs/2026-07-06-remove-cross-person-serve.md).
671
+ // Enforced HERE — before the realtime channel is even built — so a non-owner bridge
672
+ // never dials the room. Same-account second-machine coexistence is handled by the
673
+ // per-account claim_bridge, not here. LIMITATION: enforcement is in-bridge; an OLDER
674
+ // bridge may still honor a grant — but the serve_grant_uid column was dropped server-side
675
+ // in the same migration, so an old bridge can no longer READ a grant and refuses as a
676
+ // non-owner. That closes the bypass.
677
677
  let myServeUid = null // this bridge's authed uid (null = anon)
678
- let iServeAsGrantee = false // serving by grant, not as owner → the only bridge that yields
679
- let yielding = false // set once when standing down, so reclaim + revoke don't double-fire
680
678
  {
681
679
  // Standalone room mode ran anon unless TP_ACCESS_TOKEN was in env; load the stored
682
680
  // login so `npx thinkpool-pair <CODE>` authenticates AS the signed-in user (the
@@ -718,17 +716,14 @@ let yielding = false // set once when standing down, so reclaim + revoke
718
716
  const { data } = await supabase.auth.getUser(codeAuthToken)
719
717
  myServeUid = data?.user?.id || null
720
718
  }
721
- const { ownerId, grantUid, name } = await fetchServeRow({ supabaseUrl: SUPABASE_URL, anonKey: SUPABASE_ANON, token: codeAuthToken, room })
722
- roomOwnerId = ownerId
723
- const decision = serveDecision({ ownerId, grantUid, myUid: myServeUid })
719
+ const { ownerId, name } = await fetchServeRow({ supabaseUrl: SUPABASE_URL, anonKey: SUPABASE_ANON, token: codeAuthToken, room })
720
+ const decision = serveDecision({ ownerId, myUid: myServeUid })
724
721
  if (!decision.serve) {
725
722
  process.stderr.write(refusalMessage({ room, name, reason: decision.reason }))
726
723
  // exit 0 — an intentional stand-down, not a crash, so a supervisor does not
727
724
  // thrash-restart it (matches the machine-lock stand-down at the top of boot).
728
725
  process.exit(0)
729
726
  }
730
- iServeAsGrantee = amGrantee({ ownerId, grantUid, myUid: myServeUid })
731
- if (iServeAsGrantee) process.stderr.write(`\n [b:${BRIDGE_ID} r:${room}] ◆ serving room ${room} by the owner's grant — will yield if the owner's bridge returns or the grant is revoked.\n`)
732
727
  } catch (e) {
733
728
  // A THROWN fetchServeRow error is either a definitively-bad auth (401/403) or an
734
729
  // availability failure (5xx / transport reject). Only the latter fails OPEN — a stale
@@ -1038,26 +1033,8 @@ const announce = () => {
1038
1033
  ],
1039
1034
  }) }
1040
1035
 
1041
- // Owner reclaim (Contract C-CODE-3 invariant 3): a grantee-served bridge that sees
1042
- // the OWNER's bridge announce on this same room channel stands down — the owner's
1043
- // bridge always wins. Reuses the existing `bridge` announce (its additive `uid`) — no
1044
- // new topic (feedback_no_duplicate_realtime_channel). The owner's own bridge never
1045
- // yields (standDownOnPeerAnnounce returns false for it). Clean shutdown(0): shuttingDown
1046
- // suppresses the term-exit broadcast, so no structured terminal row is reaped
1047
- // (docs/specs/2026-07-04-structured-terminal-durability.md — a handover must not delete rows).
1048
- function onPeerBridgeAnnounce (payload) {
1049
- if (yielding) return
1050
- if (!standDownOnPeerAnnounce({ myUid: myServeUid, ownerId: roomOwnerId, peerUid: payload?.uid })) return
1051
- yielding = true
1052
- process.stderr.write(`\n [b:${BRIDGE_ID} r:${room}] ◆ owner's bridge reconnected — yielding room ${room}.\n`)
1053
- // Exit with the grant-reclaim SENTINEL (not a clean 0). A supervisor that spawned this
1054
- // grantee child reads the code and puts the room on a cooldown, so it does NOT respawn a
1055
- // child that will only yield again — the owner-serves/grantee-stands-by coexistence state
1056
- // must not thrash-churn every discovery tick (DEFECT 2, 2026-07-05). shutdown() still sets
1057
- // shuttingDown FIRST, so the term-exit broadcast stays suppressed and structured terminal
1058
- // rows survive the stand-down (#205/#208 durability — a handover must not delete rows).
1059
- shutdown(GRANT_RECLAIM_EXIT)
1060
- }
1036
+ // (cross-person grantee yield removed 2026-07-06 — owner-only serving now; the owner's
1037
+ // bridge never stands down. See docs/specs/2026-07-06-remove-cross-person-serve.md.)
1061
1038
 
1062
1039
  // ── Thinkpool Ensemble — cross-ROOM reach (Tier 1: same-machine pair peek) ────
1063
1040
  // The in-room Ensemble (read_terminal over sibling terminals) expanded ACROSS the
@@ -2736,9 +2713,6 @@ channel
2736
2713
  announce()
2737
2714
  })
2738
2715
  .on('broadcast', { event: 'who' }, announce)
2739
- // Peer bridge announces (owner reclaim) — a grantee yields when the owner's bridge
2740
- // reappears. broadcast self:false, so this only fires for OTHER bridges on the topic.
2741
- .on('broadcast', { event: 'bridge' }, ({ payload }) => onPeerBridgeAnnounce(payload))
2742
2716
  // ── Provider registry — the multi-BYOK wire contract (room mode). ─────────────
2743
2717
  // AUTH: room mode serves a room the user may not own (post-#208 grant). We verify
2744
2718
  // the sender is a participant of THIS room before ANY operation. List is open to
@@ -3188,26 +3162,6 @@ setInterval(() => {
3188
3162
  }
3189
3163
  }, 15000).unref()
3190
3164
 
3191
- // Grant revocation mid-serve (Contract C-CODE-3 invariant 3): a grantee re-checks the
3192
- // row on a slow cadence — a cheap poll on the EXISTING REST endpoint, no new realtime
3193
- // subscription (the owner-reclaim announce path above is the fast one). If the grant no
3194
- // longer names this bridge (cleared or reassigned), stand down. Owner bridges never poll
3195
- // (iServeAsGrantee is false for them). A flaky fetch never yields — next tick re-checks.
3196
- if (iServeAsGrantee) {
3197
- const RECHECK_MS = Math.max(15000, parseInt(process.env.TP_SERVE_RECHECK_MS, 10) || 30000)
3198
- setInterval(async () => {
3199
- if (yielding) return
3200
- try {
3201
- const { grantUid } = await fetchServeRow({ supabaseUrl: SUPABASE_URL, anonKey: SUPABASE_ANON, token: codeAuthToken, room })
3202
- if (standDownOnGrantChange({ myUid: myServeUid, ownerId: roomOwnerId, grantUid })) {
3203
- yielding = true
3204
- process.stderr.write(`\n ◆ serve grant revoked — yielding room ${room}.\n`)
3205
- shutdown(0)
3206
- }
3207
- } catch { /* transient — never yield on a flaky fetch; next tick re-checks */ }
3208
- }, RECHECK_MS).unref()
3209
- }
3210
-
3211
3165
  // ── auto-update (OS-service tier only) ─────────────────────────────────
3212
3166
  // launchd (KeepAlive) and systemd (Restart=always) re-run
3213
3167
  // `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.167",
3
+ "version": "0.7.168",
4
4
  "description": "Share a local coding-agent CLI (Claude Code, Codex, Gemini, Aider, \u2026) into a ThinkPool Code room, live.",
5
5
  "type": "module",
6
6
  "bin": {
package/serve-consent.mjs CHANGED
@@ -1,81 +1,52 @@
1
1
  // Room-serve owner consent — decision core (pure) + row fetch.
2
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.
3
+ // Spec: docs/specs/2026-07-06-remove-cross-person-serve.md (Contract C-CODE-3, evolved).
4
+ // A room is served by its OWNER's bridge ONLY. No cross-person serving, no grantee
5
+ // path (removed 2026-07-06 — the cross-person grant was a recipe for the wrong-session
6
+ // fuckery). The owner's own bridge on a second machine is handled by the per-account
7
+ // claim_bridge, not here. Pairing stays read-only (peer peek across the pair).
7
8
  //
8
9
  // 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.
10
+ // bridge/test-serve-consent.mjs) plus a single network helper. The wiring — where the
11
+ // gate runs — lives in bridge.mjs. Keeping the logic pure is the
12
+ // feedback_code_seed_order_independent discipline: no decision depends on live
13
+ // channel/session state, so every branch is testable off-wire.
13
14
  //
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.
15
+ // LIMITATION (documented): enforcement is in-bridge. A bridge OLDER than this version
16
+ // may still honor a grant — there is no server-side wire-level lock. The set_serve_grant
17
+ // RPC + serve_grant_uid column were dropped server-side in the same 2026-07-06 migration,
18
+ // so even an old bridge can no longer READ a grant (the column is gone) and will refuse
19
+ // as a non-owner. That closes the bypass.
18
20
 
19
21
  // 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
+ // ownerId : code_sessions.owner_id (uuid | null)
22
23
  // myUid : this bridge's authed user id (uuid | null) — null when the bridge is anon
23
24
  // 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
25
+ // reason 'owner' — myUid owns the room (the only serve path)
26
+ // reason 'no-identity' — bridge has no authed uid → refuse (no anon serve path)
27
+ // reason 'not-owner' — authed, but not the owner → refuse
29
28
  // reason 'unknown-room'— no owner_id resolved (row missing / RLS-hidden) → refuse
30
- export function serveDecision ({ ownerId, grantUid, myUid }) {
29
+ export function serveDecision ({ ownerId, myUid }) {
31
30
  if (!myUid) return { serve: false, reason: 'no-identity' }
32
31
  if (!ownerId) return { serve: false, reason: 'unknown-room' }
33
32
  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' }
33
+ return { serve: false, reason: 'not-owner' }
36
34
  }
37
35
 
38
36
  // supervisorServes — the account supervisor's per-row discovery decision: may this
39
- // login auto-serve THIS discovered room? True iff the login OWNS the room or HOLDS its
40
- // serve grant. This is the defense-in-depth per-row check behind the supervisor's
41
- // `.or(owner_id.eq.me,serve_grant_uid.eq.me)` query — a broad RLS row (owned OR merely
42
- // participant) must still resolve to owned-or-granted before a child is spawned. Mirrors
43
- // serveDecision's owner|grant serve set, evaluated against a discovery row rather than a
44
- // gate fetch (Contract C-CODE-2, evolved owner-only → owner-or-granted).
45
- export function supervisorServes ({ ownerId, grantUid, myUid }) {
37
+ // login auto-serve THIS discovered room? True iff the login OWNS the room. This is the
38
+ // defense-in-depth per-row check behind the supervisor's owner-only `.eq(owner_id, me)`
39
+ // query — a broad RLS row (owned OR merely participant) must still resolve to OWNED
40
+ // before a child is spawned. Contract C-CODE-2 (owner-only).
41
+ export function supervisorServes ({ ownerId, myUid }) {
46
42
  if (!myUid) return false
47
- return ownerId === myUid || grantUid === myUid
43
+ return ownerId === myUid
48
44
  }
49
45
 
50
- // amGrantee — this bridge serves by grant (not as owner). Grantees are the only
51
- // bridges that ever stand down; the owner's bridge never yields.
52
- export function amGrantee ({ ownerId, grantUid, myUid }) {
53
- return !!(myUid && myUid !== ownerId && grantUid && myUid === grantUid)
54
- }
55
-
56
- // standDownOnPeerAnnounce — a grantee-served bridge saw ANOTHER bridge announce on
57
- // the room channel. Yield iff that peer is the OWNER's bridge (peerUid === ownerId)
58
- // and I am not the owner. The owner's own bridge (myUid === ownerId) never yields —
59
- // serveDecision already refused any second non-owner, so a peer that isn't the owner
60
- // is either a stale duplicate of me or nothing to cede to.
61
- export function standDownOnPeerAnnounce ({ myUid, ownerId, peerUid }) {
62
- if (!myUid || !ownerId) return false
63
- if (myUid === ownerId) return false // owner never stands down
64
- return !!peerUid && peerUid === ownerId // owner's bridge reclaimed → yield
65
- }
66
-
67
- // standDownOnGrantChange — a grantee re-checked the row and the grant no longer
68
- // names it (revoked, or reassigned to a third party). Yield. The owner never yields
69
- // on a grant change (the grant does not govern the owner).
70
- export function standDownOnGrantChange ({ myUid, ownerId, grantUid }) {
71
- if (!myUid || myUid === ownerId) return false
72
- return grantUid !== myUid
73
- }
74
-
75
- // fetchServeRow — read the room's consent columns with whatever identity the bridge
76
- // holds (authed token → RLS returns the row if the user is a member; anon → empty).
77
- // THROWS on a non-OK response or a transport error. The thrown error carries `.authHard`
78
- // so the caller can tell the two apart (Contract C-CODE-3 invariant 2, hardened 2026-07-05):
46
+ // fetchServeRow — read the room's owner with whatever identity the bridge holds
47
+ // (authed token → RLS returns the row if the user is a member; anon → empty). THROWS on
48
+ // a non-OK response or a transport error. The thrown error carries `.authHard` so the
49
+ // caller can tell the two apart (Contract C-CODE-3 invariant 2, hardened 2026-07-05):
79
50
  // - 401/403 (authHard === true) → auth is DEFINITIVELY bad (dead/expired token). The
80
51
  // caller REFUSES: a stale access token must never fail OPEN into serving, or the gate
81
52
  // is bypassed in the common case (stored tokens expire ~1h). The caller refreshes the
@@ -88,7 +59,7 @@ export async function fetchServeRow ({ supabaseUrl, anonKey, token, room }) {
88
59
  const headers = { apikey: anonKey }
89
60
  if (token) headers.Authorization = `Bearer ${token}`
90
61
  const r = await fetch(
91
- `${supabaseUrl}/rest/v1/code_sessions?select=owner_id,serve_grant_uid,name&code=eq.${encodeURIComponent(room)}`,
62
+ `${supabaseUrl}/rest/v1/code_sessions?select=owner_id,name&code=eq.${encodeURIComponent(room)}`,
92
63
  { headers },
93
64
  )
94
65
  if (!r.ok) {
@@ -101,7 +72,6 @@ export async function fetchServeRow ({ supabaseUrl, anonKey, token, room }) {
101
72
  const row = Array.isArray(rows) ? rows[0] : null
102
73
  return {
103
74
  ownerId: row?.owner_id ?? null,
104
- grantUid: row?.serve_grant_uid ?? null,
105
75
  name: row?.name ?? null,
106
76
  }
107
77
  }
@@ -114,36 +84,13 @@ export function gateFailAction (err) {
114
84
  return err?.authHard ? 'refuse' : 'fail-open'
115
85
  }
116
86
 
117
- // ── supervisor grant-reclaim cooldown (DEFECT 2, 2026-07-05) ──────────────────
118
- // A grantee child that yields to the owner's returning bridge exits with this SENTINEL
119
- // code (distinct from a clean 0 / a crash 1). The supervisor reads the exit code and puts
120
- // the room on a cooldown so it does NOT thrash-respawn a child that will only yield again —
121
- // the feature's INTENDED coexistence state (owner serves, grantee stands by) must not churn.
122
- // A revoke-yield keeps exit 0 (discovery drops a revoked room anyway); owner-owned rooms
123
- // never yield, so they never cool down.
124
- export const GRANT_RECLAIM_EXIT = 75
125
-
126
- // shouldCooldownOnExit — did this child exit BECAUSE the owner reclaimed the room (75)?
127
- // Only that exit arms the cooldown; a clean stop (0), a crash (1), or a signal (null) do not.
128
- export function shouldCooldownOnExit (code) {
129
- return code === GRANT_RECLAIM_EXIT
130
- }
131
-
132
- // roomInCooldown — is a room still inside its grant-reclaim cooldown window? The supervisor
133
- // discovery tick skips re-serving while this is true. `until` is a wall-clock ms deadline
134
- // (Date.now() + window); a null/expired deadline means serve again (owner may have gone).
135
- export function roomInCooldown (until, now) {
136
- return !!(until && now < until)
137
- }
138
-
139
87
  // refusalMessage — the human-facing stderr line when a bridge refuses to serve.
140
88
  export function refusalMessage ({ room, name, reason }) {
141
89
  const label = name ? `"${name}"` : '(unnamed)'
142
90
  if (reason === 'no-identity') {
143
91
  return `\n ◇ Refusing to serve room ${room} — this bridge is not signed in.\n` +
144
- ` A room is served by its owner's bridge. Sign in first: npx thinkpool-pair login\n`
92
+ ` A room is served only by its owner's bridge. Sign in first: npx thinkpool-pair login\n`
145
93
  }
146
- return `\n ◇ Refusing to serve room ${room} ${label} — it is owned by another person; their bridge serves it.\n` +
147
- ` Ask them to grant your machine hosting rights in the room (bridge status card),\n` +
148
- ` then re-run. The grant is the only way in — there is no override flag.\n`
94
+ return `\n ◇ Refusing to serve room ${room} ${label} — it is owned by another person; only their bridge serves it.\n` +
95
+ ` Pairing gives you a read-only view across the pair, not hosting rights. There is no override.\n`
149
96
  }