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 +38 -2
- package/dispatch-permission-cleanup.mjs +71 -0
- package/package.json +2 -1
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.
|
|
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",
|