thinkpool-pair 0.7.247 → 0.7.248

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
@@ -110,6 +110,7 @@ const flowRedispatch = new Map()
110
110
  const flowBudgets = new Map()
111
111
  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
112
  import { dispatchPendingRecap, recoverInterruptedTurn, recoverMissingResumeOnce, sendInterruptedContinue, shouldAttemptNativeResume, supersedeInterruptedResume } from './interrupted-resume.mjs'
113
+ import { beginDispatchLease, finishDispatchLease, isDispatchLeaseCurrent, supersedeDispatchLease } from './dispatch-lease.mjs'
113
114
  import { turnInFlight } from './update-gate.mjs'
114
115
  import { saveSession, flushSession, deleteSession, loadAll, canResume, loadPtyId, savePtyId, loadNames, saveNames, appendDurableEvents, seedDurableEvents, readDurablePage, readDurableOldestSeq, hasDurableArchive, servesHistoryPage } from './session-store.mjs'
115
116
  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 +2402,16 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2401
2402
  })
2402
2403
  } catch { return okText('Dispatch preview could not be built safely. No lane was created.') }
2403
2404
  const permissionId = randomUUID()
2405
+ const dispatchAdmission = beginDispatchLease(entry, permissionId)
2406
+ if (!dispatchAdmission.ok) {
2407
+ 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.')
2408
+ }
2409
+ const dispatchLease = dispatchAdmission.lease
2410
+ try {
2404
2411
  let approval = { decision: 'deny' }
2405
2412
  try { approval = await requestDispatchApproval({ id: permissionId, input: effectiveArgs, dispatchPreview: preview }) } catch { /* fail closed */ }
2406
2413
  if (approval?.decision !== 'allow') return okText('Dispatch canceled. No lane or worktree was created.')
2414
+ if (!isDispatchLeaseCurrent(entry, dispatchLease)) return okText('That dispatch choice was superseded by a newer turn. No lane or worktree was created.')
2407
2415
  const currentNow = Date.now()
2408
2416
  const current = dispatchContext(currentNow)
2409
2417
  const durable = await readDurableDispatchAuthority({
@@ -2416,6 +2424,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2416
2424
  },
2417
2425
  })
2418
2426
  if (!durable.ok) return okText('Dispatch approval could not be verified from the durable room authority. No lane was created.')
2427
+ if (!isDispatchLeaseCurrent(entry, dispatchLease)) return okText('That dispatch choice was superseded by a newer turn. No lane or worktree was created.')
2419
2428
  const authorization = authorizeDispatchApproval({
2420
2429
  preview,
2421
2430
  approvedFingerprint: durable.fingerprint,
@@ -2477,6 +2486,9 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2477
2486
  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
2487
  }
2479
2488
  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.`)
2489
+ } finally {
2490
+ finishDispatchLease(entry, dispatchLease)
2491
+ }
2480
2492
  },
2481
2493
  )] : []),
2482
2494
  // Research lane — run a REAL multi-source search + adversarial verification and
@@ -3110,6 +3122,10 @@ function pendingResolution(pending, payload = {}) {
3110
3122
 
3111
3123
  function drainPending(s) {
3112
3124
  if (!s?.pending) return
3125
+ // Invalidate the execution fence before settling promises. A dispatch that
3126
+ // was already approved but is still re-reading durable authority must also
3127
+ // fail closed when this terminal stops or its turn settles.
3128
+ supersedeDispatchLease(s)
3113
3129
  for (const [, p] of s.pending) {
3114
3130
  if (p?.timer) clearTimeout(p.timer)
3115
3131
  try { p.resolve(pendingResolution(p, { decision: 'deny' })) } catch { /* noop */ }
@@ -3119,6 +3135,17 @@ function drainPending(s) {
3119
3135
  s.permNotifier?.clearAll()
3120
3136
  }
3121
3137
 
3138
+ function supersedePendingDispatch(s) {
3139
+ const lease = supersedeDispatchLease(s)
3140
+ if (!lease) return null
3141
+ const pending = s.pending?.get(lease.permissionId)
3142
+ if (pending?.timer) clearTimeout(pending.timer)
3143
+ s.pending?.delete(lease.permissionId)
3144
+ s.permNotifier?.resolve(lease.permissionId)
3145
+ try { pending?.resolve(pendingResolution(pending, { decision: 'deny' })) } catch { /* noop */ }
3146
+ return lease.permissionId
3147
+ }
3148
+
3122
3149
  // Switching a terminal to bypassPermissions must RETROACTIVELY clear the cards the
3123
3150
  // current turn already raised — setPermissionMode is a streaming control request that
3124
3151
  // only applies going forward, so without this a user who flips to bypass mid-turn keeps
@@ -3617,6 +3644,15 @@ channel
3617
3644
  s.hop = 0
3618
3645
  s.roomHop = 0 // a human turn is room-hop 0 — clears any injected cross-room hop depth
3619
3646
  const text = String(payload.text)
3647
+ // A fresh person-authored turn is a newer dispatch intent boundary. Retract
3648
+ // any older runtime choice and invalidate its lease before it can resume from
3649
+ // a late durable approval. Ordinary tool cards remain untouched: steering a
3650
+ // running turn must not silently answer unrelated permissions.
3651
+ const supersededDispatchId = supersedePendingDispatch(s)
3652
+ if (supersededDispatchId) {
3653
+ bcast('code-perm', { term: payload.term, id: supersededDispatchId, decision: 'deny', name: 'agent' })
3654
+ announce()
3655
+ }
3620
3656
  // A human turn supersedes a restart's still-pending auto-continue. Without this,
3621
3657
  // their message starts the cold runtime; its init event then queues a second stale
3622
3658
  // `continue` behind the request. Codex normally resumes immediately at restore, while
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.247",
3
+ "version": "0.7.248",
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,7 @@
49
49
  "cross-terminal.mjs",
50
50
  "lane-lifecycle.mjs",
51
51
  "interrupted-resume.mjs",
52
+ "dispatch-lease.mjs",
52
53
  "flow-conductor.mjs",
53
54
  "flow-worktree.mjs",
54
55
  "flow-task-graph.mjs",