thinkpool-pair 0.7.161 → 0.7.162

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
@@ -38,6 +38,7 @@ import { createRequire } from 'node:module'
38
38
  import { randomUUID } from 'node:crypto'
39
39
  import { createClient } from '@supabase/supabase-js'
40
40
  import { serveDecision, amGrantee, standDownOnPeerAnnounce, standDownOnGrantChange, fetchServeRow, refusalMessage, gateFailAction, GRANT_RECLAIM_EXIT } from './serve-consent.mjs'
41
+ import { reapTerminalRow as _reapTerminalRow } from './reap-terminal.mjs'
41
42
  // resolveProviderEnv(id) → {ANTHROPIC_BASE_URL,ANTHROPIC_AUTH_TOKEN,ANTHROPIC_MODEL} for a
42
43
  // registered custom provider, or null for the built-in/unknown (leave the default env intact).
43
44
  // Multi-provider BYOK slice 1: a lane spawned with a `provider` id runs on that endpoint.
@@ -574,6 +575,28 @@ async function refreshOwnerPlan() {
574
575
  if (rows?.[0]?.plan) ownerPlan = rows[0].plan === 'plus' ? 'plus' : 'free'
575
576
  } catch { /* DNS-flaky / offline — keep the safe default; the next token refresh retries */ }
576
577
  }
578
+
579
+ // ── Bridge-side row reap (Contract §Bridge-side reap, 2026-07-04) ───────────────
580
+ // A cleanly-closed structured lane's code_terminals row must be gone within seconds
581
+ // REGARDLESS of any client tab being open. The client-side reap (#205, room.jsx's
582
+ // `term-exit` handler) only runs in whichever browser tab happens to catch the transient
583
+ // broadcast — close a lane while no fresh tab is listening (stale/asleep) and the broadcast
584
+ // evaporates, leaving a permanent dormant ghost row (three hand-deleted 2026-07-04/05).
585
+ // The bridge is the ONE authoritative witness of a clean structured close, so it reaps the
586
+ // row itself. RLS: code_terminals_member_delete permits DELETE to any parent-session member;
587
+ // this bridge serves as the room owner or an authed grantee (codeAuthToken), so it qualifies.
588
+ // Anon bridges (no token) skip — the client reap covers them, as it does old bridges (#205
589
+ // is kept as-is). Idempotent: a fast client tab may reap first (204 / 0 rows is fine).
590
+ // Best-effort: any failure is swallowed and the client reap remains the fallback.
591
+ // Suppressed while shuttingDown — a late cleanupFlowLanes timer firing during teardown /
592
+ // stand-down / restart must NOT delete rows: dormant lanes across a restart are the feature.
593
+ // Thin wrapper: close over the bridge's LIVE state (codeAuthToken + shuttingDown are
594
+ // reassigned over the process life, so read them at call time) and delegate to the
595
+ // unit-tested reap-terminal.mjs. Fire-and-forget from endStructured — the module never
596
+ // throws (best-effort), so an unawaited promise can't reject.
597
+ function reapTerminalRow(id) {
598
+ return _reapTerminalRow({ id, token: codeAuthToken, supabaseUrl: SUPABASE_URL, anonKey: SUPABASE_ANON, shuttingDown })
599
+ }
577
600
  // ── Room-serve owner consent (Contract C-CODE-3) ──────────────────────────────
578
601
  // A room is served by its OWNER's bridge; a non-owner may serve only with the
579
602
  // owner's explicit grant (code_sessions.serve_grant_uid). Enforced HERE — before the
@@ -2213,6 +2236,8 @@ function endStructured(id) {
2213
2236
  try { s.mockupWatcher?.close() } catch { /* noop */ }
2214
2237
  sessions.delete(id)
2215
2238
  bcast('term-exit', { id })
2239
+ reapTerminalRow(id) // bridge-side reap: delete the code_terminals row ourselves — the
2240
+ // client reap (#205) evaporates if no fresh tab caught the broadcast
2216
2241
  fireImageCleanup(id) // drop this term's screenshots from Storage (COGS)
2217
2242
  // EN-m3 — closing a spawner ORPHANS the lanes it spawned (only that spawner could
2218
2243
  // close_terminal them; they can never be closed otherwise + keep occupying the caps).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.161",
3
+ "version": "0.7.162",
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": {
@@ -32,6 +32,7 @@
32
32
  "flow-skill-registry.mjs",
33
33
  "serve-dir.mjs",
34
34
  "serve-consent.mjs",
35
+ "reap-terminal.mjs",
35
36
  "service.mjs",
36
37
  "presence.mjs",
37
38
  "account.mjs",
@@ -0,0 +1,47 @@
1
+ // Bridge-side reap of a cleanly-closed structured lane's code_terminals row.
2
+ //
3
+ // Spec: docs/specs/2026-07-04-structured-terminal-durability.md §Bridge-side reap.
4
+ //
5
+ // WHY this exists: the row-reap on a clean structured close (endStructured / MCP
6
+ // close_terminal) used to be CLIENT-executed only — it ran in whichever browser tab
7
+ // caught the transient `term-exit` broadcast (room.jsx #205). Close a lane while no fresh
8
+ // tab is listening (stale / asleep) → the broadcast evaporates → a permanent dormant ghost
9
+ // row (three hand-deleted 2026-07-04/05). The bridge is the ONE authoritative witness of a
10
+ // clean structured close, so it reaps the row ITSELF — the client reap is kept as the
11
+ // fallback for old bridges and for the fast-tab case.
12
+ //
13
+ // Extracted from bridge.mjs (which has top-level side effects and can't be unit-imported)
14
+ // so the reap DECISION + REST shape are testable with an injected fetch — same pattern as
15
+ // serve-consent.mjs. bridge.mjs wraps this, closing over its live codeAuthToken /
16
+ // shuttingDown state.
17
+ //
18
+ // RLS: `code_terminals_member_delete` permits DELETE to any member of the parent code
19
+ // session; the bridge serves as the room owner or an authed grantee (codeAuthToken), so it
20
+ // qualifies. Anon bridges (no token) skip — the client reap covers them.
21
+ //
22
+ // Scope guard: NEVER reap while shuttingDown — a late cleanupFlowLanes timer firing during
23
+ // teardown / stand-down / restart must not delete rows. Dormant lanes across a restart are
24
+ // the feature, not the leak.
25
+
26
+ /**
27
+ * @returns {Promise<{reaped: boolean, reason: 'ok'|'shutdown'|'anon'|'no-id'|'error', error?: string}>}
28
+ * Never throws — best-effort; the client reap (#205) is the fallback.
29
+ */
30
+ export async function reapTerminalRow ({ id, token, supabaseUrl, anonKey, shuttingDown = false, fetchImpl = fetch }) {
31
+ if (shuttingDown) return { reaped: false, reason: 'shutdown' }
32
+ if (!token) return { reaped: false, reason: 'anon' }
33
+ if (!id) return { reaped: false, reason: 'no-id' }
34
+ try {
35
+ // Mirror of the client reap: DELETE code_terminals WHERE id = <terminal id>. The
36
+ // terminal id IS the code_terminals PK (client/bridge-minted uuid). RLS scopes the
37
+ // delete to this bridge's sessions, so id alone is sufficient + idempotent (a fast
38
+ // client tab may have reaped first — 204 / 0 rows is fine).
39
+ await fetchImpl(`${supabaseUrl}/rest/v1/code_terminals?id=eq.${encodeURIComponent(id)}`, {
40
+ method: 'DELETE',
41
+ headers: { apikey: anonKey, Authorization: `Bearer ${token}` },
42
+ })
43
+ return { reaped: true, reason: 'ok' }
44
+ } catch (e) {
45
+ return { reaped: false, reason: 'error', error: e?.message }
46
+ }
47
+ }