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 +36 -0
- package/dispatch-lease.mjs +37 -0
- package/package.json +2 -1
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.
|
|
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",
|