thinkpool-pair 0.7.295 → 0.7.296

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
@@ -39,6 +39,7 @@ import { randomUUID } from 'node:crypto'
39
39
  import { createClient } from '@supabase/supabase-js'
40
40
  import { serveDecision, fetchServeRow, refusalMessage, gateFailAction } from './serve-consent.mjs'
41
41
  import { reapTerminalRow as _reapTerminalRow } from './reap-terminal.mjs'
42
+ import { reconcileTerminalRows as _reconcileTerminalRows } from './terminal-row-reconcile.mjs'
42
43
  import { cancelDurableDispatchPermissions, cancelDurablePermissions } from './dispatch-permission-cleanup.mjs'
43
44
  // Slice 3 — the pure decision layer for agent push events (turn-done / needs-input).
44
45
  // Unit-tested in bridge/agent-notify.test.mjs; everything with I/O stays here.
@@ -46,7 +47,7 @@ import { createPermNotifier, shouldNotifyTurnDone, permissionSummary, clipSummar
46
47
  // resolveProviderEnv(id) → {ANTHROPIC_BASE_URL,ANTHROPIC_AUTH_TOKEN,ANTHROPIC_MODEL} for a
47
48
  // registered custom provider, or null for the built-in/unknown (leave the default env intact).
48
49
  // Multi-provider BYOK slice 1: a lane spawned with a `provider` id runs on that endpoint.
49
- import { resolveProviderEnv, resolveProviderRef, providerNameMap, publicKeyB64, announceProviders, listProviders, addProvider, removeProvider, unseal, effectiveLaneModel, sameProviderEnv, providerModel } from './providers.mjs'
50
+ import { resolveProviderEnv, resolveProviderRef, providerNameMap, publicKeyB64, bridgeHostId, announceProviders, listProviders, addProvider, removeProvider, unseal, effectiveLaneModel, sameProviderEnv, providerModel } from './providers.mjs'
50
51
  import { validateProviderSwitch, providerSwitchPlan, BUILTIN_PROVIDER } from './switch-provider.mjs'
51
52
  import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk'
52
53
  import { z } from 'zod'
@@ -595,6 +596,7 @@ const BRIDGE_STARTED_AT = Date.now()
595
596
  // box. Distinct from `name` (the driver's username). Announced additively —
596
597
  // top-level only (one bridge per announce); older clients ignore the unknown key.
597
598
  const host = (os.hostname() || 'host').split('.')[0].slice(0, 24)
599
+ const hostId = bridgeHostId()
598
600
 
599
601
  // Repo awareness — the room shows which project this machine is sharing.
600
602
  // Cheap reads, no subprocess: directory name + .git/HEAD.
@@ -718,6 +720,20 @@ function reapTerminalRow(id) {
718
720
  onFailure: ({ error, status }) => process.stderr.write(`\n ⚠ terminal row reap failed (${String(id).slice(0, 8)}${status ? `, HTTP ${status}` : ''}): ${error || 'unknown error'}\n`),
719
721
  })
720
722
  }
723
+
724
+ // Crash/abort counterpart to the clean-close reap above. Called only after the
725
+ // subscribed bridge has restored every on-disk session into `terms`/`sessions`.
726
+ // The stable host filter is load-bearing: another machine's dormant lanes are
727
+ // durable product state, while this machine's missing rows are stale ghosts.
728
+ function reconcileTerminalRows() {
729
+ return _reconcileTerminalRows({
730
+ room, hostId, liveIds: [...terms.keys(), ...sessions.keys()], token: codeAuthToken,
731
+ supabaseUrl: SUPABASE_URL, anonKey: SUPABASE_ANON, shuttingDown,
732
+ onFailure: ({ error, status }) => process.stderr.write(`\n ⚠ terminal row reconcile failed${status ? ` (HTTP ${status})` : ''}: ${error || 'unknown error'}\n`),
733
+ }).then(({ reaped }) => {
734
+ if (reaped.length) process.stderr.write(`\n ◆ removed ${reaped.length} stale terminal row${reaped.length === 1 ? '' : 's'} after restore.\n`)
735
+ })
736
+ }
721
737
  // Auth: is the sender a PARTICIPANT of THIS room (owner OR granted OR joined)? Room mode
722
738
  // serves the room, so RLS returns participants; we confirm the jwt's user is one of them.
723
739
  // Pattern (per #218/#219): getUser + uid → REST existence check on code_sessions
@@ -1130,9 +1146,9 @@ const announce = () => {
1130
1146
  cwd, version: VERSION, promptBundle: THINKPOOL_PROMPT_BUNDLE,
1131
1147
  // host: short machine label (see const `host`) so the room shows which box
1132
1148
  // currently serves it + attributes dormant terminals to their home machine.
1133
- // Additive top-level field; consumed by src/pages/code/room.jsx onAnnounce in
1134
- // a later lane. Older clients ignore it.
1135
- host,
1149
+ // Additive top-level fields consumed by src/pages/code/room.jsx onAnnounce.
1150
+ // Older clients ignore them.
1151
+ host, hostId,
1136
1152
  // uid: the authed user id whose bridge is serving this room (owner or grantee).
1137
1153
  // Additive field (older clients ignore it). A grantee bridge watches peer `bridge`
1138
1154
  // announces for uid === owner_id to know the owner's bridge reclaimed the room and
@@ -4460,6 +4476,7 @@ channel
4460
4476
  }
4461
4477
  }
4462
4478
  await announce()
4479
+ void reconcileTerminalRows()
4463
4480
  // Account presence must distinguish a spawned child from a usable room.
4464
4481
  // Signal only after realtime subscribed, durable sessions restored, and the
4465
4482
  // first authoritative roster was announced. The dashboard keeps the bridge
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.295",
3
+ "version": "0.7.296",
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": {
@@ -83,6 +83,7 @@
83
83
  "serve-dir.mjs",
84
84
  "serve-consent.mjs",
85
85
  "reap-terminal.mjs",
86
+ "terminal-row-reconcile.mjs",
86
87
  "switch-provider.mjs",
87
88
  "service.mjs",
88
89
  "presence.mjs",
package/providers.mjs CHANGED
@@ -100,6 +100,14 @@ export function ensureKeypair() {
100
100
  /** SPKI base64 public key for the announce (`providerPubKey`). */
101
101
  export function publicKeyB64() { return ensureKeypair().publicKeyB64 }
102
102
 
103
+ // Stable, public machine identity for terminal ownership. `host` is a display
104
+ // label and can be "localhost" on multiple computers; this fingerprint is tied
105
+ // to the bridge's existing keypair without exposing private material. Accepting
106
+ // an explicit public key keeps the identity primitive directly testable.
107
+ export function bridgeHostId(pubKey = publicKeyB64()) {
108
+ return crypto.createHash('sha256').update(pubKey).digest('hex').slice(0, 32)
109
+ }
110
+
103
111
  // ── seal / unseal (hybrid RSA-OAEP + AES-256-GCM) ───────────────────────
104
112
  // seal() is here so the UNIT can drive both ends node-side; the real seal
105
113
  // happens in the browser dashboard via WebCrypto against the same envelope.
@@ -0,0 +1,65 @@
1
+ /* Reconcile durable terminal rows after a bridge has restored its on-disk roster.
2
+
3
+ A clean close is reaped by reap-terminal.mjs. This module closes the other
4
+ lifecycle boundary: a crash/abort can remove the live session file without a
5
+ clean-close event, leaving a permanent dormant DB row. A stable public host id
6
+ makes the bridge authoritative only for its own rows; foreign/null-host rows are
7
+ deliberately preserved for handover and mixed-version compatibility. */
8
+
9
+ const outcome = (extra = {}) => ({ reaped: [], ...extra })
10
+
11
+ export async function reconcileTerminalRows ({
12
+ room,
13
+ hostId,
14
+ liveIds = [],
15
+ token,
16
+ supabaseUrl,
17
+ anonKey,
18
+ shuttingDown = false,
19
+ fetchImpl = fetch,
20
+ onFailure,
21
+ } = {}) {
22
+ if (shuttingDown) return outcome({ reason: 'shutdown' })
23
+ if (!token) return outcome({ reason: 'anon' })
24
+ if (!room) return outcome({ reason: 'no-room' })
25
+ if (!hostId) return outcome({ reason: 'no-host-id' })
26
+
27
+ const fail = (result) => {
28
+ try { onFailure?.(result) } catch { /* observability cannot break restore */ }
29
+ return outcome(result)
30
+ }
31
+
32
+ try {
33
+ const query = `${supabaseUrl}/rest/v1/code_terminals?select=id,host_id` +
34
+ `&session_code=eq.${encodeURIComponent(room)}` +
35
+ `&host_id=eq.${encodeURIComponent(hostId)}`
36
+ const response = await fetchImpl(query, {
37
+ headers: { apikey: anonKey, Authorization: `Bearer ${token}` },
38
+ })
39
+ if (!response?.ok) {
40
+ return fail({ reason: 'error', error: `list HTTP ${response?.status ?? 'unknown'}`, status: response?.status })
41
+ }
42
+
43
+ const rows = await response.json()
44
+ const live = new Set(liveIds || [])
45
+ const stale = (Array.isArray(rows) ? rows : [])
46
+ .filter((row) => row?.id && row.host_id === hostId && !live.has(row.id))
47
+ .map((row) => row.id)
48
+
49
+ const reaped = []
50
+ for (const id of stale) {
51
+ const del = await fetchImpl(
52
+ `${supabaseUrl}/rest/v1/code_terminals?id=eq.${encodeURIComponent(id)}` +
53
+ `&host_id=eq.${encodeURIComponent(hostId)}`,
54
+ { method: 'DELETE', headers: { apikey: anonKey, Authorization: `Bearer ${token}` } },
55
+ )
56
+ if (!del?.ok) {
57
+ return fail({ reason: 'error', error: `delete HTTP ${del?.status ?? 'unknown'}`, status: del?.status, reaped })
58
+ }
59
+ reaped.push(id)
60
+ }
61
+ return { reason: 'ok', reaped }
62
+ } catch (error) {
63
+ return fail({ reason: 'error', error: error?.message || String(error) })
64
+ }
65
+ }