thinkpool-pair 0.7.248 → 0.7.250

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 { cancelDurableDispatchPermissions } from './dispatch-permission-cleanup.mjs'
42
43
  // Slice 3 — the pure decision layer for agent push events (turn-done / needs-input).
43
44
  // Unit-tested in bridge/agent-notify.test.mjs; everything with I/O stays here.
44
45
  import { createPermNotifier, shouldNotifyTurnDone, permissionSummary, clipSummary, isUserFacingLane, DEFAULT_MIN_TURN_MS } from './agent-notify.mjs'
@@ -3120,12 +3121,42 @@ function pendingResolution(pending, payload = {}) {
3120
3121
  : decision
3121
3122
  }
3122
3123
 
3124
+ // The bridge is the only component that knows when the SDK promise it created
3125
+ // has died. Persist that lifecycle boundary so a card cannot outlive its
3126
+ // executable intent after a steer, stop, close, or restart.
3127
+ function cancelPendingDispatchPermissions(s, permissionIds = null, reason = 'dispatch_superseded') {
3128
+ const ids = permissionIds || [...(s?.pending?.entries?.() || [])]
3129
+ .filter(([, pending]) => pending?.payload?.answerFormat === 'dispatch')
3130
+ .map(([id]) => id)
3131
+ if (!ids.length || !s?.id) return
3132
+ void cancelDurableDispatchPermissions({
3133
+ supabaseUrl: SUPABASE_URL, anonKey: SUPABASE_ANON, token: codeAuthToken, roomCode: room,
3134
+ bridgeId: BRIDGE_ID, terminalId: s.id, permissionIds: ids, reason,
3135
+ }).then((result) => {
3136
+ if (!result.ok && process.env.TP_DEBUG) process.stderr.write(`\n ◇ durable dispatch cleanup incomplete (${result.code})\n`)
3137
+ })
3138
+ }
3139
+
3140
+ async function cancelRestoredDispatchPermissions(terminalIds) {
3141
+ const terminals = [...new Set((terminalIds || []).filter(Boolean))]
3142
+ await Promise.all(terminals.map(async (terminalId) => {
3143
+ const result = await cancelDurableDispatchPermissions({
3144
+ supabaseUrl: SUPABASE_URL, anonKey: SUPABASE_ANON, token: codeAuthToken, roomCode: room,
3145
+ // New bridge process, old durable bridge authority: filter by the
3146
+ // restored terminal and let the cleanup read each historical binding.
3147
+ terminalId, reason: 'bridge_restarted',
3148
+ })
3149
+ if (!result.ok && process.env.TP_DEBUG) process.stderr.write(`\n ◇ restored dispatch cleanup incomplete (${result.code})\n`)
3150
+ }))
3151
+ }
3152
+
3123
3153
  function drainPending(s) {
3124
3154
  if (!s?.pending) return
3125
3155
  // Invalidate the execution fence before settling promises. A dispatch that
3126
3156
  // was already approved but is still re-reading durable authority must also
3127
3157
  // fail closed when this terminal stops or its turn settles.
3128
3158
  supersedeDispatchLease(s)
3159
+ cancelPendingDispatchPermissions(s, null, 'dispatch_aborted')
3129
3160
  for (const [, p] of s.pending) {
3130
3161
  if (p?.timer) clearTimeout(p.timer)
3131
3162
  try { p.resolve(pendingResolution(p, { decision: 'deny' })) } catch { /* noop */ }
@@ -3139,6 +3170,7 @@ function supersedePendingDispatch(s) {
3139
3170
  const lease = supersedeDispatchLease(s)
3140
3171
  if (!lease) return null
3141
3172
  const pending = s.pending?.get(lease.permissionId)
3173
+ cancelPendingDispatchPermissions(s, [lease.permissionId], 'dispatch_superseded')
3142
3174
  if (pending?.timer) clearTimeout(pending.timer)
3143
3175
  s.pending?.delete(lease.permissionId)
3144
3176
  s.permNotifier?.resolve(lease.permissionId)
@@ -3650,7 +3682,6 @@ channel
3650
3682
  // running turn must not silently answer unrelated permissions.
3651
3683
  const supersededDispatchId = supersedePendingDispatch(s)
3652
3684
  if (supersededDispatchId) {
3653
- bcast('code-perm', { term: payload.term, id: supersededDispatchId, decision: 'deny', name: 'agent' })
3654
3685
  announce()
3655
3686
  }
3656
3687
  // A human turn supersedes a restart's still-pending auto-continue. Without this,
@@ -4021,7 +4052,7 @@ channel
4021
4052
  process.stderr.write(`\n ◆ lane ${String(term).slice(0, 8)} switched provider → ${target || 'anthropic'} (memory reset, transcript kept).\n`)
4022
4053
  reply(true, undefined, true)
4023
4054
  })
4024
- .subscribe(status => {
4055
+ .subscribe(async status => {
4025
4056
  if (status === 'SUBSCRIBED') {
4026
4057
  realtimeHealthy = true; brokenSince = 0
4027
4058
  trackPresence({ name, role: 'bridge', bridge_id: BRIDGE_ID })
@@ -4045,6 +4076,11 @@ channel
4045
4076
  // the tabs (Max, 2026-07-02). Legacy recs fall back to their first event ts / savedAt.
4046
4077
  const okey = (r) => r._bt || r.openedAt || r.savedAt || 0
4047
4078
  all.sort((a, b) => okey(a) - okey(b))
4079
+ // `pending` promises are intentionally process-local and never restore.
4080
+ // Cancel their durable dispatch rows BEFORE opening a restored SDK
4081
+ // session, so a new permission raised during resume can never be
4082
+ // mistaken for a pre-restart card from the same terminal.
4083
+ await cancelRestoredDispatchPermissions(all.map((rec) => rec.id))
4048
4084
  if (all.length) for (const rec of all) {
4049
4085
  const wasInterrupted = restoredTurnOpen(rec.log || [])
4050
4086
  // Codex can persist a thread id before its rollout receives the
@@ -0,0 +1,71 @@
1
+ // Durable cleanup for dispatch approvals that no longer have a live bridge
2
+ // promise behind them. A dispatch row is intentionally human-resolvable only
3
+ // while its originating bridge intent is live. When that intent is replaced
4
+ // or a bridge restarts, cancel the exact pending row through the normal
5
+ // authenticated RPC rather than leaving a misleading, non-executable card.
6
+
7
+ const PENDING = new Set(['pending'])
8
+ const SETTLED = new Set(['denied', 'idempotent_replay', 'already_resolved', 'stale_or_already_resolved', 'item_expired'])
9
+
10
+ const safeString = (value) => typeof value === 'string' && value.length > 0
11
+
12
+ export function pendingDispatchItems (items, { bridgeId = null, terminalId, permissionIds = null } = {}) {
13
+ if ((bridgeId != null && !safeString(bridgeId)) || !safeString(terminalId)) return []
14
+ const requested = permissionIds == null ? null : new Set(permissionIds.filter(safeString))
15
+ return (Array.isArray(items) ? items : []).filter((item) => {
16
+ const permissionId = item?.request_context?.permission_id
17
+ return PENDING.has(item?.status) && item?.item_kind === 'approval' && item?.action_kind === 'dispatch' &&
18
+ (!bridgeId || item?.bridge_authority_id === bridgeId) && item?.local_authority_id === terminalId &&
19
+ safeString(permissionId) && (!requested || requested.has(permissionId))
20
+ })
21
+ }
22
+
23
+ const rpc = async ({ fetchImpl, supabaseUrl, headers, name, body }) => {
24
+ const response = await fetchImpl(`${String(supabaseUrl).replace(/\/$/, '')}/rest/v1/rpc/${name}`, {
25
+ method: 'POST', headers, body: JSON.stringify(body),
26
+ })
27
+ if (!response?.ok) throw new Error(`${name}_failed`)
28
+ return response.json()
29
+ }
30
+
31
+ // Fetch through list_code_pair_controls so RLS applies to the bridge's current
32
+ // room-member identity. Never accept an id supplied by a public broadcast.
33
+ export async function cancelDurableDispatchPermissions ({
34
+ fetchImpl = globalThis.fetch, supabaseUrl, anonKey, token, roomCode, bridgeId,
35
+ terminalId, permissionIds = null, reason = 'dispatch_superseded',
36
+ } = {}) {
37
+ if (typeof fetchImpl !== 'function' || !safeString(supabaseUrl) || !safeString(anonKey) ||
38
+ !safeString(token) || !safeString(roomCode) || (bridgeId != null && !safeString(bridgeId)) || !safeString(terminalId)) {
39
+ return Object.freeze({ ok: false, code: 'cleanup_unavailable', canceled: 0 })
40
+ }
41
+ if (permissionIds != null && (!Array.isArray(permissionIds) || !permissionIds.length)) {
42
+ return Object.freeze({ ok: true, code: 'nothing_to_cancel', canceled: 0 })
43
+ }
44
+ const headers = { apikey: anonKey, Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }
45
+ try {
46
+ const listed = await rpc({ fetchImpl, supabaseUrl, headers, name: 'list_code_pair_controls', body: {
47
+ p_session_code: String(roomCode).toUpperCase(), p_status: null, p_limit: 100,
48
+ } })
49
+ const candidates = pendingDispatchItems(listed, { bridgeId, terminalId, permissionIds })
50
+ const outcomes = await Promise.all(candidates.map(async (item) => {
51
+ const result = await rpc({ fetchImpl, supabaseUrl, headers, name: 'resolve_code_pair_control', body: {
52
+ p_item_id: item.id,
53
+ p_expected_version: item.version,
54
+ p_decision: 'deny',
55
+ p_resolution_code: 'canceled',
56
+ p_resolution_payload: { decision: 'deny', reason },
57
+ // A restart receives a fresh bridge ID. The old row remains protected
58
+ // by its immutable authority binding, so resolve it with that exact
59
+ // value after the owner-authenticated RLS list—not a guessed current ID.
60
+ p_bridge_authority_id: item.bridge_authority_id,
61
+ p_local_authority_id: terminalId,
62
+ p_idempotency_key: `cancel-dispatch:${item.id}:${reason}`.slice(0, 160),
63
+ } })
64
+ return { id: item.id, code: result?.code || 'invalid_response', ok: Boolean(result?.ok) && SETTLED.has(result.code) }
65
+ }))
66
+ const canceled = outcomes.filter((outcome) => outcome.ok).length
67
+ return Object.freeze({ ok: canceled === outcomes.length, code: canceled === outcomes.length ? 'canceled' : 'cleanup_partial', canceled, outcomes: Object.freeze(outcomes) })
68
+ } catch {
69
+ return Object.freeze({ ok: false, code: 'cleanup_failed', canceled: 0 })
70
+ }
71
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.248",
3
+ "version": "0.7.250",
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": {
@@ -50,6 +50,7 @@
50
50
  "lane-lifecycle.mjs",
51
51
  "interrupted-resume.mjs",
52
52
  "dispatch-lease.mjs",
53
+ "dispatch-permission-cleanup.mjs",
53
54
  "flow-conductor.mjs",
54
55
  "flow-worktree.mjs",
55
56
  "flow-task-graph.mjs",