thinkpool-pair 0.7.248 → 0.7.249

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,40 @@ 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
+ bridgeId: BRIDGE_ID, terminalId, reason: 'bridge_restarted',
3146
+ })
3147
+ if (!result.ok && process.env.TP_DEBUG) process.stderr.write(`\n ◇ restored dispatch cleanup incomplete (${result.code})\n`)
3148
+ }))
3149
+ }
3150
+
3123
3151
  function drainPending(s) {
3124
3152
  if (!s?.pending) return
3125
3153
  // Invalidate the execution fence before settling promises. A dispatch that
3126
3154
  // was already approved but is still re-reading durable authority must also
3127
3155
  // fail closed when this terminal stops or its turn settles.
3128
3156
  supersedeDispatchLease(s)
3157
+ cancelPendingDispatchPermissions(s, null, 'dispatch_aborted')
3129
3158
  for (const [, p] of s.pending) {
3130
3159
  if (p?.timer) clearTimeout(p.timer)
3131
3160
  try { p.resolve(pendingResolution(p, { decision: 'deny' })) } catch { /* noop */ }
@@ -3139,6 +3168,7 @@ function supersedePendingDispatch(s) {
3139
3168
  const lease = supersedeDispatchLease(s)
3140
3169
  if (!lease) return null
3141
3170
  const pending = s.pending?.get(lease.permissionId)
3171
+ cancelPendingDispatchPermissions(s, [lease.permissionId], 'dispatch_superseded')
3142
3172
  if (pending?.timer) clearTimeout(pending.timer)
3143
3173
  s.pending?.delete(lease.permissionId)
3144
3174
  s.permNotifier?.resolve(lease.permissionId)
@@ -3650,7 +3680,6 @@ channel
3650
3680
  // running turn must not silently answer unrelated permissions.
3651
3681
  const supersededDispatchId = supersedePendingDispatch(s)
3652
3682
  if (supersededDispatchId) {
3653
- bcast('code-perm', { term: payload.term, id: supersededDispatchId, decision: 'deny', name: 'agent' })
3654
3683
  announce()
3655
3684
  }
3656
3685
  // A human turn supersedes a restart's still-pending auto-continue. Without this,
@@ -4021,7 +4050,7 @@ channel
4021
4050
  process.stderr.write(`\n ◆ lane ${String(term).slice(0, 8)} switched provider → ${target || 'anthropic'} (memory reset, transcript kept).\n`)
4022
4051
  reply(true, undefined, true)
4023
4052
  })
4024
- .subscribe(status => {
4053
+ .subscribe(async status => {
4025
4054
  if (status === 'SUBSCRIBED') {
4026
4055
  realtimeHealthy = true; brokenSince = 0
4027
4056
  trackPresence({ name, role: 'bridge', bridge_id: BRIDGE_ID })
@@ -4045,6 +4074,11 @@ channel
4045
4074
  // the tabs (Max, 2026-07-02). Legacy recs fall back to their first event ts / savedAt.
4046
4075
  const okey = (r) => r._bt || r.openedAt || r.savedAt || 0
4047
4076
  all.sort((a, b) => okey(a) - okey(b))
4077
+ // `pending` promises are intentionally process-local and never restore.
4078
+ // Cancel their durable dispatch rows BEFORE opening a restored SDK
4079
+ // session, so a new permission raised during resume can never be
4080
+ // mistaken for a pre-restart card from the same terminal.
4081
+ await cancelRestoredDispatchPermissions(all.map((rec) => rec.id))
4048
4082
  if (all.length) for (const rec of all) {
4049
4083
  const wasInterrupted = restoredTurnOpen(rec.log || [])
4050
4084
  // Codex can persist a thread id before its rollout receives the
@@ -0,0 +1,68 @@
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, terminalId, permissionIds = null } = {}) {
13
+ if (!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
+ 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) || !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
+ p_bridge_authority_id: bridgeId,
58
+ p_local_authority_id: terminalId,
59
+ p_idempotency_key: `cancel-dispatch:${item.id}:${reason}`.slice(0, 160),
60
+ } })
61
+ return { id: item.id, code: result?.code || 'invalid_response', ok: Boolean(result?.ok) && SETTLED.has(result.code) }
62
+ }))
63
+ const canceled = outcomes.filter((outcome) => outcome.ok).length
64
+ return Object.freeze({ ok: canceled === outcomes.length, code: canceled === outcomes.length ? 'canceled' : 'cleanup_partial', canceled, outcomes: Object.freeze(outcomes) })
65
+ } catch {
66
+ return Object.freeze({ ok: false, code: 'cleanup_failed', canceled: 0 })
67
+ }
68
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.248",
3
+ "version": "0.7.249",
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",