thinkpool-pair 0.7.161 → 0.7.163

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,10 +38,11 @@ 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.
44
- import { resolveProviderEnv } from './providers.mjs'
45
+ import { resolveProviderEnv, providerNameMap } from './providers.mjs'
45
46
  import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk'
46
47
  import { z } from 'zod'
47
48
  import { startClaudeSession } from './claude-session.mjs'
@@ -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
@@ -862,8 +885,12 @@ const termNames = loadNames(room)
862
885
  // a hardcoded shorthand set on other devices / after reload). Max 2026-07-02.
863
886
  let roomModels = null
864
887
 
865
- const announce = () =>
866
- bcast('bridge', {
888
+ const announce = () => {
889
+ // Per-lane provider projection: resolve the registry id→NAME once per announce
890
+ // (name-only, NEVER key/baseUrl) so a cross-device viewer can badge a lane opened
891
+ // on a custom provider without the owner's account-channel registry.
892
+ const provNames = providerNameMap()
893
+ return bcast('bridge', {
867
894
  v: 2, name, repo: repoLabel, branch: readBranch(),
868
895
  // sdkWarn: the auto-pulled agent SDK failed its boot compatibility smoke test —
869
896
  // surfaced so the room can show a banner (turns may misbehave; pin a good SDK).
@@ -922,9 +949,15 @@ const announce = () =>
922
949
  // turnActive). Authoritative — the bridge owns the turn — so the /code
923
950
  // dashboard reads "N agents working" off the roster instead of sniffing
924
951
  // the live code-event stream (which a passive dashboard channel misses).
925
- ...[...sessions.entries()].map(([id, s]) => ({ id, cmd: s.cmd, kind: 'structured', alive: true, busy: s.session?.turnActive ?? false, commands: s.commands, mode: s.mode || undefined, name: termNames[id] || undefined, model: s.model || undefined, ...(s.archiveOldestSeq != null ? { oldestSeq: s.archiveOldestSeq } : {}), ...(s.spawnedBy ? { spawned: true, spawnedBy: s.spawnedBy } : {}), ...(s.flowSessionId ? { flowId: s.flowSessionId, flowRole: s.flowTaskKey ? 'lane' : 'conductor' } : {}) })),
952
+ ...[...sessions.entries()].map(([id, s]) => ({ id, cmd: s.cmd, kind: 'structured', alive: true, busy: s.session?.turnActive ?? false, commands: s.commands, mode: s.mode || undefined, name: termNames[id] || undefined, model: s.model || undefined, ...(s.archiveOldestSeq != null ? { oldestSeq: s.archiveOldestSeq } : {}), ...(s.spawnedBy ? { spawned: true, spawnedBy: s.spawnedBy } : {}), ...(s.flowSessionId ? { flowId: s.flowSessionId, flowRole: s.flowTaskKey ? 'lane' : 'conductor' } : {}),
953
+ // provider: the registered LLM-provider this lane runs on, NAME-ONLY {id,name}
954
+ // (NEVER the key or baseUrl). Additive; older clients ignore it. Omitted for the
955
+ // built-in/default Claude path (no badge). Makes the lane's provider badge +
956
+ // /model honesty cross-device (was device-local via localStorage) and survives a
957
+ // bridge restart (entry.provider is persisted with the session).
958
+ ...(s.provider && s.provider !== 'anthropic' ? { provider: { id: s.provider, name: provNames[s.provider] || s.provider } } : {}) })),
926
959
  ],
927
- })
960
+ }) }
928
961
 
929
962
  // Owner reclaim (Contract C-CODE-3 invariant 3): a grantee-served bridge that sees
930
963
  // the OWNER's bridge announce on this same room channel stands down — the owner's
@@ -2213,6 +2246,8 @@ function endStructured(id) {
2213
2246
  try { s.mockupWatcher?.close() } catch { /* noop */ }
2214
2247
  sessions.delete(id)
2215
2248
  bcast('term-exit', { id })
2249
+ reapTerminalRow(id) // bridge-side reap: delete the code_terminals row ourselves — the
2250
+ // client reap (#205) evaporates if no fresh tab caught the broadcast
2216
2251
  fireImageCleanup(id) // drop this term's screenshots from Storage (COGS)
2217
2252
  // EN-m3 — closing a spawner ORPHANS the lanes it spawned (only that spawner could
2218
2253
  // 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.163",
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",
package/providers.mjs CHANGED
@@ -211,6 +211,20 @@ export function announceProviders() {
211
211
  return [{ id: BUILTIN_ID, name: 'Anthropic (Claude)', model: null }, ...custom]
212
212
  }
213
213
 
214
+ /**
215
+ * id → name map for the ROOM announce's per-lane provider projection (name-only,
216
+ * NEVER key or baseUrl). Built once per announce so a cross-device viewer resolves
217
+ * a lane's provider badge without the owner's account-channel registry. Built-in
218
+ * `anthropic` is omitted (the default path shows no badge). A stale/removed id has
219
+ * no entry; the caller falls back to the bare id (a random UUID — not key material).
220
+ * @returns {Record<string,string>}
221
+ */
222
+ export function providerNameMap() {
223
+ const m = {}
224
+ for (const p of loadProviders()) if (p?.id && p.id !== BUILTIN_ID) m[p.id] = p.name
225
+ return m
226
+ }
227
+
214
228
  function keyHint(key) {
215
229
  const k = String(key || '')
216
230
  return k.length <= 4 ? k : k.slice(-4)
@@ -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
+ }