thinkpool-pair 0.7.247 → 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'
@@ -110,6 +111,7 @@ const flowRedispatch = new Map()
110
111
  const flowBudgets = new Map()
111
112
  import { formatPeek, PEEK, siblingsOf, resolveSibling, crossPostDecision, CROSSPOST, spawnDecision, SPAWN, CROSSROOM, formatPairRoster, crossRoomPostDecision, formatRoomNow, formatClosableHint, closeLaneDecision, laneStatusOf, nativeClaudeFallbackHint, buildDispatchPreview, authorizeDispatchApproval, verifyDurableDispatchAuthority } from './cross-terminal.mjs'
112
113
  import { dispatchPendingRecap, recoverInterruptedTurn, recoverMissingResumeOnce, sendInterruptedContinue, shouldAttemptNativeResume, supersedeInterruptedResume } from './interrupted-resume.mjs'
114
+ import { beginDispatchLease, finishDispatchLease, isDispatchLeaseCurrent, supersedeDispatchLease } from './dispatch-lease.mjs'
113
115
  import { turnInFlight } from './update-gate.mjs'
114
116
  import { saveSession, flushSession, deleteSession, loadAll, canResume, loadPtyId, savePtyId, loadNames, saveNames, appendDurableEvents, seedDurableEvents, readDurablePage, readDurableOldestSeq, hasDurableArchive, servesHistoryPage } from './session-store.mjs'
115
117
  import { stampEvent, makeSeqCounter, maxSeq, seqable, capReplayEvents, chunkReplayEvents, boundEventForBroadcast, inlineImageBlocks, ImageEventQueue, imageQueueConfig, uploadCodeImage as uploadCodeImageRequest, usageReportLine, codexUsageReportLine, buildRecapFromLog, RECAP_CAP, trimmedBeforeSeq, firstSeq } from './event-id.mjs'
@@ -2401,9 +2403,16 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2401
2403
  })
2402
2404
  } catch { return okText('Dispatch preview could not be built safely. No lane was created.') }
2403
2405
  const permissionId = randomUUID()
2406
+ const dispatchAdmission = beginDispatchLease(entry, permissionId)
2407
+ if (!dispatchAdmission.ok) {
2408
+ return okText('A dispatch choice is already awaiting approval in the room. Do not retry or switch runtimes; wait for that choice to be approved or canceled, or for a new human turn to supersede it.')
2409
+ }
2410
+ const dispatchLease = dispatchAdmission.lease
2411
+ try {
2404
2412
  let approval = { decision: 'deny' }
2405
2413
  try { approval = await requestDispatchApproval({ id: permissionId, input: effectiveArgs, dispatchPreview: preview }) } catch { /* fail closed */ }
2406
2414
  if (approval?.decision !== 'allow') return okText('Dispatch canceled. No lane or worktree was created.')
2415
+ if (!isDispatchLeaseCurrent(entry, dispatchLease)) return okText('That dispatch choice was superseded by a newer turn. No lane or worktree was created.')
2407
2416
  const currentNow = Date.now()
2408
2417
  const current = dispatchContext(currentNow)
2409
2418
  const durable = await readDurableDispatchAuthority({
@@ -2416,6 +2425,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2416
2425
  },
2417
2426
  })
2418
2427
  if (!durable.ok) return okText('Dispatch approval could not be verified from the durable room authority. No lane was created.')
2428
+ if (!isDispatchLeaseCurrent(entry, dispatchLease)) return okText('That dispatch choice was superseded by a newer turn. No lane or worktree was created.')
2419
2429
  const authorization = authorizeDispatchApproval({
2420
2430
  preview,
2421
2431
  approvedFingerprint: durable.fingerprint,
@@ -2477,6 +2487,9 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2477
2487
  return okText(`Opened agent lane ${newRef}${args?.name ? ` ("${args.name}")` : ''} and handed it the task. It runs in its own lane — check back with read_terminal, then close_terminal when done.`)
2478
2488
  }
2479
2489
  return okText(`Opened idle agent lane ${newRef}${args?.name ? ` ("${args.name}")` : ''}. Hand it work with post_to_terminal, or a person can type into it.`)
2490
+ } finally {
2491
+ finishDispatchLease(entry, dispatchLease)
2492
+ }
2480
2493
  },
2481
2494
  )] : []),
2482
2495
  // Research lane — run a REAL multi-source search + adversarial verification and
@@ -3108,8 +3121,40 @@ function pendingResolution(pending, payload = {}) {
3108
3121
  : decision
3109
3122
  }
3110
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
+
3111
3151
  function drainPending(s) {
3112
3152
  if (!s?.pending) return
3153
+ // Invalidate the execution fence before settling promises. A dispatch that
3154
+ // was already approved but is still re-reading durable authority must also
3155
+ // fail closed when this terminal stops or its turn settles.
3156
+ supersedeDispatchLease(s)
3157
+ cancelPendingDispatchPermissions(s, null, 'dispatch_aborted')
3113
3158
  for (const [, p] of s.pending) {
3114
3159
  if (p?.timer) clearTimeout(p.timer)
3115
3160
  try { p.resolve(pendingResolution(p, { decision: 'deny' })) } catch { /* noop */ }
@@ -3119,6 +3164,18 @@ function drainPending(s) {
3119
3164
  s.permNotifier?.clearAll()
3120
3165
  }
3121
3166
 
3167
+ function supersedePendingDispatch(s) {
3168
+ const lease = supersedeDispatchLease(s)
3169
+ if (!lease) return null
3170
+ const pending = s.pending?.get(lease.permissionId)
3171
+ cancelPendingDispatchPermissions(s, [lease.permissionId], 'dispatch_superseded')
3172
+ if (pending?.timer) clearTimeout(pending.timer)
3173
+ s.pending?.delete(lease.permissionId)
3174
+ s.permNotifier?.resolve(lease.permissionId)
3175
+ try { pending?.resolve(pendingResolution(pending, { decision: 'deny' })) } catch { /* noop */ }
3176
+ return lease.permissionId
3177
+ }
3178
+
3122
3179
  // Switching a terminal to bypassPermissions must RETROACTIVELY clear the cards the
3123
3180
  // current turn already raised — setPermissionMode is a streaming control request that
3124
3181
  // only applies going forward, so without this a user who flips to bypass mid-turn keeps
@@ -3617,6 +3674,14 @@ channel
3617
3674
  s.hop = 0
3618
3675
  s.roomHop = 0 // a human turn is room-hop 0 — clears any injected cross-room hop depth
3619
3676
  const text = String(payload.text)
3677
+ // A fresh person-authored turn is a newer dispatch intent boundary. Retract
3678
+ // any older runtime choice and invalidate its lease before it can resume from
3679
+ // a late durable approval. Ordinary tool cards remain untouched: steering a
3680
+ // running turn must not silently answer unrelated permissions.
3681
+ const supersededDispatchId = supersedePendingDispatch(s)
3682
+ if (supersededDispatchId) {
3683
+ announce()
3684
+ }
3620
3685
  // A human turn supersedes a restart's still-pending auto-continue. Without this,
3621
3686
  // their message starts the cold runtime; its init event then queues a second stale
3622
3687
  // `continue` behind the request. Codex normally resumes immediately at restore, while
@@ -3985,7 +4050,7 @@ channel
3985
4050
  process.stderr.write(`\n ◆ lane ${String(term).slice(0, 8)} switched provider → ${target || 'anthropic'} (memory reset, transcript kept).\n`)
3986
4051
  reply(true, undefined, true)
3987
4052
  })
3988
- .subscribe(status => {
4053
+ .subscribe(async status => {
3989
4054
  if (status === 'SUBSCRIBED') {
3990
4055
  realtimeHealthy = true; brokenSince = 0
3991
4056
  trackPresence({ name, role: 'bridge', bridge_id: BRIDGE_ID })
@@ -4009,6 +4074,11 @@ channel
4009
4074
  // the tabs (Max, 2026-07-02). Legacy recs fall back to their first event ts / savedAt.
4010
4075
  const okey = (r) => r._bt || r.openedAt || r.savedAt || 0
4011
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))
4012
4082
  if (all.length) for (const rec of all) {
4013
4083
  const wasInterrupted = restoredTurnOpen(rec.log || [])
4014
4084
  // Codex can persist a thread id before its rollout receives the
@@ -0,0 +1,37 @@
1
+ // One live dispatch intent per structured terminal.
2
+ //
3
+ // Durable permission rows can outlive the agent turn that requested them. The
4
+ // lease is the bridge-local execution fence: arguments still need the durable
5
+ // fingerprint/authority checks, and the matching intent must also remain current
6
+ // until the lane is created. A newer human turn or abort supersedes the lease, so
7
+ // a late (otherwise valid) approval cannot resurrect an old runtime choice.
8
+
9
+ const nextGeneration = (entry) =>
10
+ (Number.isSafeInteger(entry?.dispatchGeneration) ? entry.dispatchGeneration : 0) + 1
11
+
12
+ export function beginDispatchLease (entry, permissionId) {
13
+ if (!entry || !permissionId) return Object.freeze({ ok: false, code: 'invalid_dispatch' })
14
+ if (entry.dispatchLease) return Object.freeze({ ok: false, code: 'dispatch_pending', active: entry.dispatchLease })
15
+ const lease = Object.freeze({ permissionId, generation: nextGeneration(entry) })
16
+ entry.dispatchGeneration = lease.generation
17
+ entry.dispatchLease = lease
18
+ return Object.freeze({ ok: true, lease })
19
+ }
20
+
21
+ export function isDispatchLeaseCurrent (entry, lease) {
22
+ return Boolean(entry && lease && entry.dispatchLease === lease && entry.dispatchGeneration === lease.generation)
23
+ }
24
+
25
+ export function finishDispatchLease (entry, lease) {
26
+ if (!isDispatchLeaseCurrent(entry, lease)) return false
27
+ entry.dispatchLease = null
28
+ return true
29
+ }
30
+
31
+ export function supersedeDispatchLease (entry) {
32
+ const lease = entry?.dispatchLease || null
33
+ if (!entry || !lease) return null
34
+ entry.dispatchGeneration = nextGeneration(entry)
35
+ entry.dispatchLease = null
36
+ return lease
37
+ }
@@ -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.247",
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": {
@@ -49,6 +49,8 @@
49
49
  "cross-terminal.mjs",
50
50
  "lane-lifecycle.mjs",
51
51
  "interrupted-resume.mjs",
52
+ "dispatch-lease.mjs",
53
+ "dispatch-permission-cleanup.mjs",
52
54
  "flow-conductor.mjs",
53
55
  "flow-worktree.mjs",
54
56
  "flow-task-graph.mjs",