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 +71 -1
- package/dispatch-lease.mjs +37 -0
- package/dispatch-permission-cleanup.mjs +68 -0
- package/package.json +3 -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'
|
|
@@ -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.
|
|
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",
|