thinkpool-pair 0.7.158 → 0.7.160

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
@@ -16,6 +16,7 @@ import os from 'node:os'
16
16
  import { saveAuth, loadAuth, loadDirs, bindDir, loadServed, rememberServed, loadDefaultDir, saveDefaultDir, claudeRecentProjectDir } from './auth-store.mjs'
17
17
  import { isSafeToRestart } from './update-gate.mjs'
18
18
  import { makeThrottledTrack } from './presence.mjs'
19
+ import { supervisorServes, shouldCooldownOnExit, roomInCooldown } from './serve-consent.mjs'
19
20
  import { resolveServeDir } from './serve-dir.mjs'
20
21
  import { pairKeyFor, pairTopic, CROSSROOM_BUS } from './cross-terminal.mjs'
21
22
 
@@ -230,6 +231,13 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
230
231
  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}`)
231
232
 
232
233
  const children = new Map() // room -> child process
234
+ // grantCooldown: room -> wall-clock ms deadline. A grantee child that yielded to the
235
+ // owner's returning bridge (exit 75) parks its room here so discovery does NOT re-spawn a
236
+ // child that would only yield again — the owner-serves/grantee-stands-by coexistence state
237
+ // must not thrash-churn every 15s tick (DEFECT 2, 2026-07-05). Expires after TP_GRANT_COOLDOWN_MS;
238
+ // after that discovery retries once — serving normally if the owner has since gone.
239
+ const grantCooldown = new Map()
240
+ const GRANT_COOLDOWN_MS = Math.max(15_000, parseInt(process.env.TP_GRANT_COOLDOWN_MS, 10) || 120_000)
233
241
  const childIdle = new Map() // room -> bool (last idle report: in the quiet window)
234
242
  const childBetween = new Map() // room -> bool (between turns now — safe to restart per Contract #1)
235
243
  const childPeer = new Map() // room -> bool (a web client is watching this room)
@@ -529,26 +537,39 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
529
537
  return
530
538
  }
531
539
  let rooms = []
540
+ const me = session.user.id
532
541
  try {
533
- // Owner-only discovery (Contract C-CODE-2): a host bridges ONLY the sessions
534
- // it CREATED. RLS on code_sessions returns owned OR participant rooms, so
535
- // without this owner filter the supervisor auto-serves rooms you merely JOINED
536
- // as a guest — spawning your machine into another owner's session that already
537
- // runs its own bridge. The web watches tpacct:<session-owner>, so a
538
- // participant's bridge is duplication the UI never even looks at.
539
- const { data } = await sb.from('code_sessions').select('code,name,last_active_at,owner_id,participants').eq('owner_id', session.user.id).order('last_active_at', { ascending: false }).limit(20)
542
+ // Discovery (Contract C-CODE-2, evolved owner-only → owner-OR-granted): a host
543
+ // bridges the sessions it OWNS *plus* any it has been explicitly granted to serve
544
+ // (code_sessions.serve_grant_uid = me — the owner-consent grant, #206/#208). RLS
545
+ // returns owned OR participant rooms, and a grantee is ALWAYS a participant
546
+ // (set_serve_grant requires it), so a granted row is visible to this login. Without
547
+ // a filter the supervisor would auto-serve rooms you merely JOINED as a guest —
548
+ // spawning your machine into another owner's session that already runs its own
549
+ // bridge; the .or keeps un-granted joins out while letting a consented grant IN.
550
+ // The web watches tpacct:<session-owner>; a granted bridge serves on the owner's
551
+ // behalf (the grant IS the consent), so it is wanted duplication, not stray.
552
+ 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)
540
553
  rooms = data || []
541
- // Prune refused entries for rooms we no longer own (only on a SUCCESSFUL fetch,
542
- // so a transient query error never wipes the set).
543
- const ownedCodes = new Set(rooms.map((r) => r.code))
544
- for (const c of [...refused.keys()]) if (!ownedCodes.has(c)) refused.delete(c)
554
+ // Prune refused entries for rooms we can no longer serve — owned OR granted (only
555
+ // on a SUCCESSFUL fetch, so a transient query error never wipes the set).
556
+ const servableCodes = new Set(rooms.map((r) => r.code))
557
+ for (const c of [...refused.keys()]) if (!servableCodes.has(c)) refused.delete(c)
545
558
  } catch { /* transient — keep existing children, retry next tick */ }
546
559
  const dirs = loadDirs()
547
560
  const served = loadServed()
548
561
  for (const r of rooms) {
549
562
  const room = r.code
550
563
  if (!room || children.has(room)) { if (room) refused.delete(room); continue } // served → not refused
551
- if (r.owner_id && r.owner_id !== session.user.id) continue // never serve a room we don't own (defense-in-depth behind the .eq above)
564
+ // Grant-reclaim cooldown (DEFECT 2): this room's grantee child recently yielded to the
565
+ // owner's bridge (exit 75). Skip re-serving it until the window elapses so we don't
566
+ // thrash-respawn a child that will only yield again. refused already reads 'owner-serves'
567
+ // (set on that exit below), so presence stays honest while we stand by.
568
+ const cdUntil = grantCooldown.get(room)
569
+ if (roomInCooldown(cdUntil, Date.now())) continue
570
+ if (cdUntil) grantCooldown.delete(room) // window elapsed — allow one fresh attempt (owner may have gone)
571
+ // Defense-in-depth behind the .or above: serve iff we OWN the room or hold its grant.
572
+ if (!supervisorServes({ ownerId: r.owner_id, grantUid: r.serve_grant_uid, myUid: me })) continue
552
573
  // Resolve the serve dir: explicit bind > last-served memory > launch dir. REFUSE
553
574
  // to auto-serve an unbound room from $HOME — that strips the agent's resume context
554
575
  // (cwd-keyed SDK sessions) and shows your home folder as the repo (FVIHV1DE).
@@ -666,13 +687,22 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
666
687
  try { w.reply(m) } catch { /* reply path gone */ }
667
688
  }
668
689
  })
669
- const dropRoom = () => {
690
+ const dropRoom = (code) => {
670
691
  children.delete(room); childIdle.delete(room); childBetween.delete(room); childPeer.delete(room); roomNames.delete(room); roomPartner.delete(room) // re-served on the next tick
692
+ // Owner-reclaim SENTINEL (75): the grantee child stood down because the owner's bridge
693
+ // returned. Park the room on a cooldown so discovery doesn't respawn it into another
694
+ // immediate yield (DEFECT 2), and mark refused 'owner-serves' so presence is honest.
695
+ // A revoke-yield exits 0 (discovery drops a revoked room anyway); owner-owned rooms
696
+ // never yield, so neither cools down. `code` is undefined on the 'error' path — fine.
697
+ if (shouldCooldownOnExit(code)) {
698
+ grantCooldown.set(room, Date.now() + GRANT_COOLDOWN_MS)
699
+ refused.set(room, 'owner-serves')
700
+ }
671
701
  // Fail any in-flight cross-room peeks/posts this dead room had ASKED (bus-origin
672
702
  // entries have askerRoom null and just time out on their own).
673
703
  for (const [reqId, w] of pairPending) { if (w?.askerRoom === room) pairPending.delete(reqId) }
674
704
  }
675
- child.on('exit', dropRoom)
705
+ child.on('exit', (code) => dropRoom(code))
676
706
  // A spawn that never starts (execPath/cwd vanished between the existsSync check and
677
707
  // spawn) emits 'error', NOT 'exit'. Without this handler the stale children-map entry
678
708
  // would block re-serving forever AND an unhandled 'error' would crash the supervisor.
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 } from './serve-consent.mjs'
40
+ import { serveDecision, amGrantee, standDownOnPeerAnnounce, standDownOnGrantChange, fetchServeRow, refusalMessage, gateFailAction, GRANT_RECLAIM_EXIT } from './serve-consent.mjs'
41
41
  import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk'
42
42
  import { z } from 'zod'
43
43
  import { startClaudeSession } from './claude-session.mjs'
@@ -586,8 +586,33 @@ let yielding = false // set once when standing down, so reclaim + revoke
586
586
  // Standalone room mode ran anon unless TP_ACCESS_TOKEN was in env; load the stored
587
587
  // login so `npx thinkpool-pair <CODE>` authenticates AS the signed-in user (the
588
588
  // identity the consent check needs). Account children already carry the env token.
589
+ // Refresh the stored access token BEFORE the gate (DEFECT 1, 2026-07-05). A stored
590
+ // access token is minted at login and Supabase access tokens expire ~1h; a long-lived
591
+ // standalone bridge — or a service relaunched from a stale store — would otherwise hit
592
+ // the RLS-gated gate with a DEAD token: fetchServeRow → 401, which (old behavior) masked
593
+ // as a network error and FAILED OPEN → the consent gate was bypassed in the common case.
594
+ // authedClient exchanges the rotated refresh token for a live pair and persists it via
595
+ // saveAuth (refresh-token rotation: the used token is now dead, so the store must never be
596
+ // left on the stale pair). Only refresh when the token came from the STORE — an env token
597
+ // (TP_ACCESS_TOKEN, from a supervisor that already keeps it fresh) is left untouched.
598
+ let tokenFromStore = false
589
599
  if (!codeAuthToken) {
590
- try { const { loadAuth } = await import('./auth-store.mjs'); codeAuthToken = loadAuth()?.access_token || null } catch { /* no store — stays anon */ }
600
+ try {
601
+ const { loadAuth } = await import('./auth-store.mjs')
602
+ codeAuthToken = loadAuth()?.access_token || null
603
+ if (codeAuthToken) tokenFromStore = true
604
+ } catch { /* no store — stays anon */ }
605
+ }
606
+ if (tokenFromStore) {
607
+ try {
608
+ const { authedClient } = await import('./account.mjs')
609
+ const refreshed = await authedClient(SUPABASE_URL, SUPABASE_ANON)
610
+ if (refreshed?.session?.access_token) codeAuthToken = refreshed.session.access_token
611
+ // If the refresh FAILS (dead refresh token / offline), keep the stale token: an
612
+ // offline refresh must not strand a legit owner. A dead refresh token then yields a
613
+ // 401 at fetchServeRow → refuse (correct: the login is genuinely gone); a transient
614
+ // offline refresh yields a fetch reject → fail-open (correct: availability).
615
+ } catch { /* account module / refresh failed — proceed with the stored token */ }
591
616
  }
592
617
  try {
593
618
  if (codeAuthToken) {
@@ -606,8 +631,15 @@ let yielding = false // set once when standing down, so reclaim + revoke
606
631
  iServeAsGrantee = amGrantee({ ownerId, grantUid, myUid: myServeUid })
607
632
  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
633
  } 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.
634
+ // A THROWN fetchServeRow error is either a definitively-bad auth (401/403) or an
635
+ // availability failure (5xx / transport reject). Only the latter fails OPEN — a stale
636
+ // 401 must NEVER serve, or the gate is bypassed (DEFECT 1). gateFailAction classifies.
637
+ if (gateFailAction(e) === 'refuse') {
638
+ process.stderr.write(refusalMessage({ room, name: null, reason: 'no-identity' }))
639
+ process.stderr.write(`\n ◇ (serve-consent: auth rejected at the gate — ${e.message}; the stored login is expired or revoked. Re-run: npx thinkpool-pair login)\n`)
640
+ process.exit(0)
641
+ }
642
+ // Availability failure → fail OPEN (spec: never strand a user on a flaky fetch).
611
643
  process.stderr.write(`\n ◇ serve-consent check degraded (${e.message}); proceeding without it.\n`)
612
644
  }
613
645
  }
@@ -902,7 +934,13 @@ function onPeerBridgeAnnounce (payload) {
902
934
  if (!standDownOnPeerAnnounce({ myUid: myServeUid, ownerId: roomOwnerId, peerUid: payload?.uid })) return
903
935
  yielding = true
904
936
  process.stderr.write(`\n ◆ owner's bridge reconnected — yielding room ${room}.\n`)
905
- shutdown(0)
937
+ // Exit with the grant-reclaim SENTINEL (not a clean 0). A supervisor that spawned this
938
+ // grantee child reads the code and puts the room on a cooldown, so it does NOT respawn a
939
+ // child that will only yield again — the owner-serves/grantee-stands-by coexistence state
940
+ // must not thrash-churn every discovery tick (DEFECT 2, 2026-07-05). shutdown() still sets
941
+ // shuttingDown FIRST, so the term-exit broadcast stays suppressed and structured terminal
942
+ // rows survive the stand-down (#205/#208 durability — a handover must not delete rows).
943
+ shutdown(GRANT_RECLAIM_EXIT)
906
944
  }
907
945
 
908
946
  // ── Thinkpool Ensemble — cross-ROOM reach (Tier 1: same-machine pair peek) ────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.158",
3
+ "version": "0.7.160",
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": {
package/serve-consent.mjs CHANGED
@@ -35,6 +35,18 @@ export function serveDecision ({ ownerId, grantUid, myUid }) {
35
35
  return { serve: false, reason: 'not-granted' }
36
36
  }
37
37
 
38
+ // 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 }) {
46
+ if (!myUid) return false
47
+ return ownerId === myUid || grantUid === myUid
48
+ }
49
+
38
50
  // amGrantee — this bridge serves by grant (not as owner). Grantees are the only
39
51
  // bridges that ever stand down; the owner's bridge never yields.
40
52
  export function amGrantee ({ ownerId, grantUid, myUid }) {
@@ -62,9 +74,16 @@ export function standDownOnGrantChange ({ myUid, ownerId, grantUid }) {
62
74
 
63
75
  // fetchServeRow — read the room's consent columns with whatever identity the bridge
64
76
  // 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).
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):
79
+ // - 401/403 (authHard === true) → auth is DEFINITIVELY bad (dead/expired token). The
80
+ // caller REFUSES: a stale access token must never fail OPEN into serving, or the gate
81
+ // is bypassed in the common case (stored tokens expire ~1h). The caller refreshes the
82
+ // token BEFORE the gate, so a 401 here means the refresh also failed → genuinely unauthed.
83
+ // - 5xx / genuine fetch() reject (authHard === false) → availability failure. The caller
84
+ // fails OPEN (never strand a user on a flaky network / a Supabase blip).
85
+ // A clean HTTP response that simply yields no row is NOT a throw — it returns
86
+ // { ownerId: null } and the caller refuses (unknown room).
68
87
  export async function fetchServeRow ({ supabaseUrl, anonKey, token, room }) {
69
88
  const headers = { apikey: anonKey }
70
89
  if (token) headers.Authorization = `Bearer ${token}`
@@ -72,7 +91,12 @@ export async function fetchServeRow ({ supabaseUrl, anonKey, token, room }) {
72
91
  `${supabaseUrl}/rest/v1/code_sessions?select=owner_id,serve_grant_uid,name&code=eq.${encodeURIComponent(room)}`,
73
92
  { headers },
74
93
  )
75
- if (!r.ok) throw new Error(`code_sessions fetch ${r.status}`)
94
+ if (!r.ok) {
95
+ const err = new Error(`code_sessions fetch ${r.status}`)
96
+ err.status = r.status
97
+ err.authHard = r.status === 401 || r.status === 403
98
+ throw err
99
+ }
76
100
  const rows = await r.json()
77
101
  const row = Array.isArray(rows) ? rows[0] : null
78
102
  return {
@@ -82,6 +106,36 @@ export async function fetchServeRow ({ supabaseUrl, anonKey, token, room }) {
82
106
  }
83
107
  }
84
108
 
109
+ // gateFailAction — pure classifier for a THROWN fetchServeRow error at the boot gate.
110
+ // 'refuse' — auth is definitively bad (401/403 after a token refresh); do NOT fail open.
111
+ // 'fail-open' — an availability error (5xx, offline, transport reject); serve rather than
112
+ // strand a user on a flaky fetch. This is the ONLY fail-open path.
113
+ export function gateFailAction (err) {
114
+ return err?.authHard ? 'refuse' : 'fail-open'
115
+ }
116
+
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
+
85
139
  // refusalMessage — the human-facing stderr line when a bridge refuses to serve.
86
140
  export function refusalMessage ({ room, name, reason }) {
87
141
  const label = name ? `"${name}"` : '(unnamed)'