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 +36 -2
- package/dispatch-permission-cleanup.mjs +68 -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,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.
|
|
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",
|