thinkpool-pair 0.7.159 → 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 +26 -3
- package/bridge.mjs +43 -5
- package/package.json +1 -1
- package/serve-consent.mjs +46 -4
package/account.mjs
CHANGED
|
@@ -16,7 +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 } from './serve-consent.mjs'
|
|
19
|
+
import { supervisorServes, shouldCooldownOnExit, roomInCooldown } from './serve-consent.mjs'
|
|
20
20
|
import { resolveServeDir } from './serve-dir.mjs'
|
|
21
21
|
import { pairKeyFor, pairTopic, CROSSROOM_BUS } from './cross-terminal.mjs'
|
|
22
22
|
|
|
@@ -231,6 +231,13 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
231
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}`)
|
|
232
232
|
|
|
233
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)
|
|
234
241
|
const childIdle = new Map() // room -> bool (last idle report: in the quiet window)
|
|
235
242
|
const childBetween = new Map() // room -> bool (between turns now — safe to restart per Contract #1)
|
|
236
243
|
const childPeer = new Map() // room -> bool (a web client is watching this room)
|
|
@@ -554,6 +561,13 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
554
561
|
for (const r of rooms) {
|
|
555
562
|
const room = r.code
|
|
556
563
|
if (!room || children.has(room)) { if (room) refused.delete(room); continue } // served → not refused
|
|
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)
|
|
557
571
|
// Defense-in-depth behind the .or above: serve iff we OWN the room or hold its grant.
|
|
558
572
|
if (!supervisorServes({ ownerId: r.owner_id, grantUid: r.serve_grant_uid, myUid: me })) continue
|
|
559
573
|
// Resolve the serve dir: explicit bind > last-served memory > launch dir. REFUSE
|
|
@@ -673,13 +687,22 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
673
687
|
try { w.reply(m) } catch { /* reply path gone */ }
|
|
674
688
|
}
|
|
675
689
|
})
|
|
676
|
-
const dropRoom = () => {
|
|
690
|
+
const dropRoom = (code) => {
|
|
677
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
|
+
}
|
|
678
701
|
// Fail any in-flight cross-room peeks/posts this dead room had ASKED (bus-origin
|
|
679
702
|
// entries have askerRoom null and just time out on their own).
|
|
680
703
|
for (const [reqId, w] of pairPending) { if (w?.askerRoom === room) pairPending.delete(reqId) }
|
|
681
704
|
}
|
|
682
|
-
child.on('exit', dropRoom)
|
|
705
|
+
child.on('exit', (code) => dropRoom(code))
|
|
683
706
|
// A spawn that never starts (execPath/cwd vanished between the existsSync check and
|
|
684
707
|
// spawn) emits 'error', NOT 'exit'. Without this handler the stale children-map entry
|
|
685
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 {
|
|
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
|
-
//
|
|
610
|
-
//
|
|
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
|
-
|
|
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
package/serve-consent.mjs
CHANGED
|
@@ -74,9 +74,16 @@ export function standDownOnGrantChange ({ myUid, ownerId, grantUid }) {
|
|
|
74
74
|
|
|
75
75
|
// fetchServeRow — read the room's consent columns with whatever identity the bridge
|
|
76
76
|
// holds (authed token → RLS returns the row if the user is a member; anon → empty).
|
|
77
|
-
// THROWS on a
|
|
78
|
-
//
|
|
79
|
-
//
|
|
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).
|
|
80
87
|
export async function fetchServeRow ({ supabaseUrl, anonKey, token, room }) {
|
|
81
88
|
const headers = { apikey: anonKey }
|
|
82
89
|
if (token) headers.Authorization = `Bearer ${token}`
|
|
@@ -84,7 +91,12 @@ export async function fetchServeRow ({ supabaseUrl, anonKey, token, room }) {
|
|
|
84
91
|
`${supabaseUrl}/rest/v1/code_sessions?select=owner_id,serve_grant_uid,name&code=eq.${encodeURIComponent(room)}`,
|
|
85
92
|
{ headers },
|
|
86
93
|
)
|
|
87
|
-
if (!r.ok)
|
|
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
|
+
}
|
|
88
100
|
const rows = await r.json()
|
|
89
101
|
const row = Array.isArray(rows) ? rows[0] : null
|
|
90
102
|
return {
|
|
@@ -94,6 +106,36 @@ export async function fetchServeRow ({ supabaseUrl, anonKey, token, room }) {
|
|
|
94
106
|
}
|
|
95
107
|
}
|
|
96
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
|
+
|
|
97
139
|
// refusalMessage — the human-facing stderr line when a bridge refuses to serve.
|
|
98
140
|
export function refusalMessage ({ room, name, reason }) {
|
|
99
141
|
const label = name ? `"${name}"` : '(unnamed)'
|