thinkpool-pair 0.7.243 → 0.7.244
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 +174 -23
- package/code-event-contract.mjs +106 -0
- package/cross-terminal.mjs +141 -2
- package/event-id.mjs +6 -2
- package/lane-lifecycle.mjs +181 -0
- package/package.json +4 -1
- package/pair-control-authority.mjs +76 -0
package/bridge.mjs
CHANGED
|
@@ -105,11 +105,13 @@ const flowRedispatch = new Map()
|
|
|
105
105
|
// wave BEFORE overrun. Lives bridge-side because waves dispatch across separate
|
|
106
106
|
// broadcasts; without persistent state the cap can never bite.
|
|
107
107
|
const flowBudgets = new Map()
|
|
108
|
-
import { formatPeek, PEEK, siblingsOf, resolveSibling, crossPostDecision, CROSSPOST, spawnDecision, SPAWN, CROSSROOM, formatPairRoster, crossRoomPostDecision, formatRoomNow, formatClosableHint, closeLaneDecision, laneStatusOf, nativeClaudeFallbackHint } from './cross-terminal.mjs'
|
|
108
|
+
import { formatPeek, PEEK, siblingsOf, resolveSibling, crossPostDecision, CROSSPOST, spawnDecision, SPAWN, CROSSROOM, formatPairRoster, crossRoomPostDecision, formatRoomNow, formatClosableHint, closeLaneDecision, laneStatusOf, nativeClaudeFallbackHint, buildDispatchPreview, authorizeDispatchApproval, verifyDurableDispatchAuthority } from './cross-terminal.mjs'
|
|
109
109
|
import { dispatchPendingRecap, recoverInterruptedTurn, recoverMissingResumeOnce, sendInterruptedContinue, shouldAttemptNativeResume, supersedeInterruptedResume } from './interrupted-resume.mjs'
|
|
110
110
|
import { turnInFlight } from './update-gate.mjs'
|
|
111
111
|
import { saveSession, flushSession, deleteSession, loadAll, canResume, loadPtyId, savePtyId, loadNames, saveNames, appendDurableEvents, seedDurableEvents, readDurablePage, readDurableOldestSeq, hasDurableArchive, servesHistoryPage } from './session-store.mjs'
|
|
112
112
|
import { stampEvent, makeSeqCounter, maxSeq, seqable, capReplayEvents, chunkReplayEvents, boundEventForBroadcast, inlineImageBlocks, usageReportLine, codexUsageReportLine, buildRecapFromLog, RECAP_CAP, trimmedBeforeSeq, firstSeq } from './event-id.mjs'
|
|
113
|
+
import { classifyCodeEvent, unknownCodeEventNotice } from './code-event-contract.mjs'
|
|
114
|
+
import { fetchPairControlDeliveryAuthority } from './pair-control-authority.mjs'
|
|
113
115
|
import { SIDE_HANDOFF_PROMPT, SIDE_ROLE_PROMPT, appendSideContext, assistantTextSince, sideContextBlock, sideSnapshot } from './side-lane.mjs'
|
|
114
116
|
import { planMeterLine } from './plan-meters.mjs'
|
|
115
117
|
import { makeThrottledTrack } from './presence.mjs'
|
|
@@ -643,6 +645,31 @@ const supabase = createClient(SUPABASE_URL, SUPABASE_ANON, {
|
|
|
643
645
|
realtime: { params: { eventsPerSecond: 60 } },
|
|
644
646
|
})
|
|
645
647
|
|
|
648
|
+
// Authenticated re-read for previewed Dispatch. Realtime is intentionally not
|
|
649
|
+
// trusted here: any room member can observe the public wake-up frame. RLS plus
|
|
650
|
+
// the bridge's live member token supplies the item/event rows that the pure
|
|
651
|
+
// verifier consumes. Timeout/offline/malformed state all fail closed.
|
|
652
|
+
async function readDurableDispatchAuthority ({ controlItemId, expected, timeoutMs = 5000 } = {}) {
|
|
653
|
+
if (!codeAuthToken || !/^[0-9a-f-]{36}$/i.test(String(controlItemId || ''))) return { ok: false, code: 'durable_authority_unavailable' }
|
|
654
|
+
const controller = new AbortController()
|
|
655
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
656
|
+
timer.unref?.()
|
|
657
|
+
const headers = { apikey: SUPABASE_ANON, Authorization: `Bearer ${codeAuthToken}` }
|
|
658
|
+
try {
|
|
659
|
+
const itemUrl = `${SUPABASE_URL}/rest/v1/code_pair_control_items?id=eq.${encodeURIComponent(controlItemId)}&select=*`
|
|
660
|
+
const eventsUrl = `${SUPABASE_URL}/rest/v1/code_pair_control_events?item_id=eq.${encodeURIComponent(controlItemId)}&select=*&order=created_at.asc`
|
|
661
|
+
const [itemRes, eventRes] = await Promise.all([
|
|
662
|
+
fetch(itemUrl, { headers, signal: controller.signal }),
|
|
663
|
+
fetch(eventsUrl, { headers, signal: controller.signal }),
|
|
664
|
+
])
|
|
665
|
+
if (!itemRes.ok || !eventRes.ok) return { ok: false, code: 'durable_authority_read_failed' }
|
|
666
|
+
const [items, events] = await Promise.all([itemRes.json(), eventRes.json()])
|
|
667
|
+
if (!Array.isArray(items) || items.length !== 1 || !Array.isArray(events)) return { ok: false, code: 'durable_authority_shape' }
|
|
668
|
+
return verifyDurableDispatchAuthority({ item: items[0], events, expected: { ...expected, controlItemId } })
|
|
669
|
+
} catch { return { ok: false, code: 'durable_authority_unavailable' } }
|
|
670
|
+
finally { clearTimeout(timer) }
|
|
671
|
+
}
|
|
672
|
+
|
|
646
673
|
// Owner's plan (free|plus) — gates the Ensemble dispatch ceiling (Free 3 / Plus 6).
|
|
647
674
|
// Resolved from the owner token (codeAuthToken); RLS lets a user read their OWN
|
|
648
675
|
// profiles row. Cached; defaults to 'free' (the safe lower ceiling) until the fetch
|
|
@@ -1032,7 +1059,7 @@ const announce = () => {
|
|
|
1032
1059
|
// on a custom provider without the owner's account-channel registry.
|
|
1033
1060
|
const provNames = providerNameMap()
|
|
1034
1061
|
return bcast('bridge', {
|
|
1035
|
-
v: 2, name, repo: repoLabel, branch: readBranch(),
|
|
1062
|
+
v: 2, name, bridge_id: BRIDGE_ID, repo: repoLabel, branch: readBranch(),
|
|
1036
1063
|
// sdkWarn: the auto-pulled agent SDK failed its boot compatibility smoke test —
|
|
1037
1064
|
// surfaced so the room can show a banner (turns may misbehave; pin a good SDK).
|
|
1038
1065
|
...(sdkStatus.ok === false ? { sdkWarn: `${sdkStatus.version}: ${sdkStatus.reason}` } : {}),
|
|
@@ -1142,6 +1169,16 @@ function pairRequest(type, payload = {}, timeoutMs = 4000) {
|
|
|
1142
1169
|
catch { clearTimeout(timer); pairWaiters.delete(reqId); resolve({ error: 'Could not reach the account bridge.' }) }
|
|
1143
1170
|
})
|
|
1144
1171
|
}
|
|
1172
|
+
function attachDurablePermissionSource(entry, payload) {
|
|
1173
|
+
const event = {
|
|
1174
|
+
kind: 'needs-input', subtype: 'pair-control',
|
|
1175
|
+
summary: 'A protected action requires a durable decision.',
|
|
1176
|
+
permissionId: String(payload?.id || '').slice(0, 160),
|
|
1177
|
+
}
|
|
1178
|
+
pushLog(entry, event)
|
|
1179
|
+
bcast('code-event', { term: entry.id, evt: event })
|
|
1180
|
+
return { ...payload, source: { epoch: 0, cid: event.cid, seq: event.seq } }
|
|
1181
|
+
}
|
|
1145
1182
|
// ── Tier 3 — raise a standalone permission card on a target session ──────────
|
|
1146
1183
|
// The cross-ROOM post lands here on the RECEIVING side: a person in THIS room must
|
|
1147
1184
|
// approve the incoming task before it injects (the second half of dual consent —
|
|
@@ -1152,7 +1189,7 @@ function pairRequest(type, payload = {}, timeoutMs = 4000) {
|
|
|
1152
1189
|
function raiseCard(entry, req) {
|
|
1153
1190
|
return new Promise((resolve) => {
|
|
1154
1191
|
if (!entry?.pending) return resolve('deny')
|
|
1155
|
-
const payload = { term: entry.id, id: req.id, toolName: req.toolName, input: req.input, risk: req.risk }
|
|
1192
|
+
const payload = attachDurablePermissionSource(entry, { term: entry.id, id: req.id, toolName: req.toolName, input: req.input, risk: req.risk })
|
|
1156
1193
|
entry.pending.set(req.id, { resolve, payload })
|
|
1157
1194
|
announce()
|
|
1158
1195
|
bcast('code-perm-req', payload)
|
|
@@ -1620,11 +1657,27 @@ function printLocal(evt) {
|
|
|
1620
1657
|
}
|
|
1621
1658
|
}
|
|
1622
1659
|
|
|
1660
|
+
// Emit a safe degradation record for an unknown provider/client event. The rejected
|
|
1661
|
+
// payload never reaches a client or disk; the bridge-observed rejection does.
|
|
1662
|
+
function recordUnknownCodeEvent(entry, evt) {
|
|
1663
|
+
const notice = unknownCodeEventNotice(evt)
|
|
1664
|
+
process.stderr.write(`\n ◇ ${notice.message}\n`)
|
|
1665
|
+
if (entry?.id) {
|
|
1666
|
+
pushLog(entry, notice)
|
|
1667
|
+
bcast('code-event', { term: entry.id, evt: notice })
|
|
1668
|
+
}
|
|
1669
|
+
return notice
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1623
1672
|
// Single stamp+append point for a structured session's replayed log. Assigns the
|
|
1624
1673
|
// per-term contiguous seq (C1), appends, and caps at STRUCTURED_LOG_MAX (oldest
|
|
1625
1674
|
// first). EVERY event that joins entry.log must go through here so the client's
|
|
1626
1675
|
// "drop ≤ seqHi" cursor stays correct (a logged event with no seq would break it).
|
|
1627
1676
|
function pushLog(entry, evt) {
|
|
1677
|
+
if (!classifyCodeEvent(evt).known) {
|
|
1678
|
+
recordUnknownCodeEvent(entry, evt)
|
|
1679
|
+
return null
|
|
1680
|
+
}
|
|
1628
1681
|
// Single stamp point: guarantee ts+cid on EVERY logged event. echoYou() and the
|
|
1629
1682
|
// /model,/clear control lines called pushLog WITHOUT a prior stampEvent, so
|
|
1630
1683
|
// `you`/`control` were logged with a seq but NO ts. On a long session the
|
|
@@ -2039,6 +2092,34 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2039
2092
|
provider: args?.provider,
|
|
2040
2093
|
}
|
|
2041
2094
|
}
|
|
2095
|
+
const dispatchContext = (now = Date.now()) => ({
|
|
2096
|
+
runtime: entry.runtime,
|
|
2097
|
+
mode: entry.mode,
|
|
2098
|
+
hop: entry.hop || 0,
|
|
2099
|
+
spawnTimes: entry.spawnTimes || [],
|
|
2100
|
+
now,
|
|
2101
|
+
spawnedLive: [...sessions.values()].filter((session) => session.spawnedBy).length,
|
|
2102
|
+
totalLive: sessions.size + terms.size,
|
|
2103
|
+
plan: ownerPlan,
|
|
2104
|
+
disabled: process.env.TP_SPAWN_OFF === '1',
|
|
2105
|
+
})
|
|
2106
|
+
// Dispatch uses the same durable pair-control permission transport as every
|
|
2107
|
+
// protected action. The preview is additive evidence on that one request;
|
|
2108
|
+
// this Promise is not another approval store or an autonomous allow path.
|
|
2109
|
+
const requestDispatchApproval = (req) => new Promise((resolve) => {
|
|
2110
|
+
const payload = attachDurablePermissionSource(entry, {
|
|
2111
|
+
term: id, id: req.id, toolName: 'mcp__thinkpool__spawn_terminal',
|
|
2112
|
+
// Exact arguments are already bound into dispatchPreview.fingerprint. Do not
|
|
2113
|
+
// copy raw tool input into the public wake-up frame or the reconnect cache.
|
|
2114
|
+
risk: 'high', answerFormat: 'dispatch',
|
|
2115
|
+
dispatchPreview: req.dispatchPreview,
|
|
2116
|
+
})
|
|
2117
|
+
entry.pending.set(req.id, { resolve, payload, timer: null })
|
|
2118
|
+
announce()
|
|
2119
|
+
bcast('code-perm-req', payload)
|
|
2120
|
+
if (isUserFacingLane(entry)) entry.permNotifier?.arm(req.id, permissionSummary(payload))
|
|
2121
|
+
process.stderr.write(`\n ${A.yel}● Dispatch preview ready — approve or cancel in the room.${A.rst}\n`)
|
|
2122
|
+
})
|
|
2042
2123
|
const consumeTerminalOpenBudget = () => {
|
|
2043
2124
|
const now = Date.now()
|
|
2044
2125
|
const gate = spawnDecision({
|
|
@@ -2280,15 +2361,8 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2280
2361
|
const resolved = resolveAgentOpen(args, { worker: true })
|
|
2281
2362
|
if (resolved.error) return okText(resolved.error)
|
|
2282
2363
|
const now = Date.now()
|
|
2283
|
-
const
|
|
2284
|
-
|
|
2285
|
-
spawnTimes: entry.spawnTimes || [], // sliding-window burst breaker (replenishes on wall-clock, not on a human turn)
|
|
2286
|
-
now,
|
|
2287
|
-
spawnedLive: [...sessions.values()].filter((s) => s.spawnedBy).length, // dispatch budget counts ONLY spawned lanes
|
|
2288
|
-
totalLive: sessions.size + terms.size, // machine cap counts everything
|
|
2289
|
-
plan: ownerPlan, // Free 3 / Plus 6 dispatch ceiling
|
|
2290
|
-
disabled: process.env.TP_SPAWN_OFF === '1',
|
|
2291
|
-
})
|
|
2364
|
+
const before = dispatchContext(now)
|
|
2365
|
+
const gate = spawnDecision(before)
|
|
2292
2366
|
// A refusal that says "close one" without saying WHICH one sends the caller
|
|
2293
2367
|
// hunting. Adapt the live roster to the pure helper: only lanes THIS caller
|
|
2294
2368
|
// spawned and that aren't mid-turn — the exact set close_terminal accepts.
|
|
@@ -2298,9 +2372,63 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2298
2372
|
spawnedBy: s.spawnedBy,
|
|
2299
2373
|
busy: (s.session?.turnActive ?? false) || (s.pending?.size ?? 0) > 0,
|
|
2300
2374
|
}))))
|
|
2375
|
+
const effectiveArgs = {
|
|
2376
|
+
...args,
|
|
2377
|
+
runtime: resolved.runtime,
|
|
2378
|
+
model: resolved.model,
|
|
2379
|
+
provider: resolved.provider,
|
|
2380
|
+
mode: resolved.mode,
|
|
2381
|
+
}
|
|
2382
|
+
let preview
|
|
2383
|
+
try {
|
|
2384
|
+
preview = buildDispatchPreview({
|
|
2385
|
+
args: effectiveArgs,
|
|
2386
|
+
initiatorTerminalId: id,
|
|
2387
|
+
roomCode: room,
|
|
2388
|
+
bridgeAuthorityId: BRIDGE_ID,
|
|
2389
|
+
localAuthorityId: id,
|
|
2390
|
+
context: {
|
|
2391
|
+
...before,
|
|
2392
|
+
providerName: resolved.provider ? (providerNameMap()[resolved.provider] || resolved.provider) : undefined,
|
|
2393
|
+
resolvedModel: resolved.model,
|
|
2394
|
+
},
|
|
2395
|
+
})
|
|
2396
|
+
} catch { return okText('Dispatch preview could not be built safely. No lane was created.') }
|
|
2397
|
+
const permissionId = randomUUID()
|
|
2398
|
+
let approval = { decision: 'deny' }
|
|
2399
|
+
try { approval = await requestDispatchApproval({ id: permissionId, input: effectiveArgs, dispatchPreview: preview }) } catch { /* fail closed */ }
|
|
2400
|
+
if (approval?.decision !== 'allow') return okText('Dispatch canceled. No lane or worktree was created.')
|
|
2401
|
+
const currentNow = Date.now()
|
|
2402
|
+
const current = dispatchContext(currentNow)
|
|
2403
|
+
const durable = await readDurableDispatchAuthority({
|
|
2404
|
+
controlItemId: approval.controlItemId,
|
|
2405
|
+
expected: {
|
|
2406
|
+
roomCode: room,
|
|
2407
|
+
bridgeAuthorityId: BRIDGE_ID,
|
|
2408
|
+
localAuthorityId: id,
|
|
2409
|
+
fingerprint: preview.fingerprint,
|
|
2410
|
+
},
|
|
2411
|
+
})
|
|
2412
|
+
if (!durable.ok) return okText('Dispatch approval could not be verified from the durable room authority. No lane was created.')
|
|
2413
|
+
const authorization = authorizeDispatchApproval({
|
|
2414
|
+
preview,
|
|
2415
|
+
approvedFingerprint: durable.fingerprint,
|
|
2416
|
+
args: effectiveArgs,
|
|
2417
|
+
initiatorTerminalId: id,
|
|
2418
|
+
roomCode: room,
|
|
2419
|
+
bridgeAuthorityId: BRIDGE_ID,
|
|
2420
|
+
localAuthorityId: id,
|
|
2421
|
+
context: {
|
|
2422
|
+
...current,
|
|
2423
|
+
providerName: resolved.provider ? (providerNameMap()[resolved.provider] || resolved.provider) : undefined,
|
|
2424
|
+
resolvedModel: resolved.model,
|
|
2425
|
+
},
|
|
2426
|
+
permissionId,
|
|
2427
|
+
})
|
|
2428
|
+
if (!authorization.ok) return okText(authorization.reason || 'Dispatch approval is stale or does not match this request. No lane was created.')
|
|
2301
2429
|
// Record this spawn, pruning timestamps that have already left the window
|
|
2302
2430
|
// (identical predicate to recentSpawnCount) so the array can't grow unbounded.
|
|
2303
|
-
entry.spawnTimes = [...(entry.spawnTimes || []).filter((t) => Number.isFinite(t) && t >
|
|
2431
|
+
entry.spawnTimes = [...(entry.spawnTimes || []).filter((t) => Number.isFinite(t) && t > currentNow - SPAWN.windowMs), currentNow]
|
|
2304
2432
|
const newId = randomUUID()
|
|
2305
2433
|
const newRef = String(newId).slice(0, 8)
|
|
2306
2434
|
const fromRef = String(id).slice(0, 8)
|
|
@@ -2570,6 +2698,15 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2570
2698
|
// default Claude env is left exactly as-is (unchanged path).
|
|
2571
2699
|
env: { ...process.env, ...buildConductorEnv({ flowSessionId, mode }), ...(resolveProviderEnv(provider) || {}), TP_MOCKUP_OUTBOX: mockupOutbox },
|
|
2572
2700
|
onEvent: (evt) => {
|
|
2701
|
+
if (!classifyCodeEvent(evt).known) {
|
|
2702
|
+
// Never archive or replay an unknown provider payload as a known fact. Do
|
|
2703
|
+
// leave a durable, user-visible bridge error so a provider upgrade cannot
|
|
2704
|
+
// silently strand a busy lane or erase its failure from the transcript.
|
|
2705
|
+
const notice = recordUnknownCodeEvent(entry, evt)
|
|
2706
|
+
printLocal(notice)
|
|
2707
|
+
announce()
|
|
2708
|
+
return
|
|
2709
|
+
}
|
|
2573
2710
|
// Self-heal a stale resume — the saved SDK session expired. Reopen fresh,
|
|
2574
2711
|
// keeping the transcript (scrollback survives; live context is gone).
|
|
2575
2712
|
if (resume && evt.kind === 'error') {
|
|
@@ -2869,7 +3006,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2869
3006
|
}
|
|
2870
3007
|
// Keep the broadcast payload with the resolver so a reconnect can re-send it
|
|
2871
3008
|
// (replay-request handler) — pending cards never enter the replayed event log.
|
|
2872
|
-
const payload = { term: id, id: req.id, toolName: req.toolName, input: req.input, risk: req.risk, plan: req.plan, questions: req.questions, asker: req.asker, answerFormat: req.answerFormat, autoResolutionMs: req.autoResolutionMs }
|
|
3009
|
+
const payload = attachDurablePermissionSource(entry, { term: id, id: req.id, toolName: req.toolName, input: req.input, risk: req.risk, plan: req.plan, questions: req.questions, asker: req.asker, answerFormat: req.answerFormat, autoResolutionMs: req.autoResolutionMs })
|
|
2873
3010
|
const pending = { resolve, payload, timer: null }
|
|
2874
3011
|
entry.pending.set(req.id, pending)
|
|
2875
3012
|
announce()
|
|
@@ -2934,7 +3071,9 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2934
3071
|
// hook already degrades to, so draining to deny is always safe.
|
|
2935
3072
|
function pendingResolution(pending, payload = {}) {
|
|
2936
3073
|
const decision = payload.decision || 'deny'
|
|
2937
|
-
return pending?.payload?.answerFormat === '
|
|
3074
|
+
return pending?.payload?.answerFormat === 'dispatch'
|
|
3075
|
+
? { decision, dispatchFingerprint: payload.dispatchFingerprint || null, controlItemId: payload.controlItemId || null }
|
|
3076
|
+
: pending?.payload?.answerFormat === 'codex'
|
|
2938
3077
|
? { decision, answers: payload.answers || {} }
|
|
2939
3078
|
: decision
|
|
2940
3079
|
}
|
|
@@ -2964,7 +3103,7 @@ function allowPending(s) {
|
|
|
2964
3103
|
// AskUserQuestion (risk:'ask') is a deliberate human gate — flipping to bypass means
|
|
2965
3104
|
// "run tools without asking", not "auto-approve my plan / answer my question" — leave
|
|
2966
3105
|
// those for the human.
|
|
2967
|
-
if (p?.payload?.risk === 'plan' || p?.payload?.risk === 'ask') continue
|
|
3106
|
+
if (p?.payload?.risk === 'plan' || p?.payload?.risk === 'ask' || p?.payload?.answerFormat === 'dispatch') continue
|
|
2968
3107
|
if (p?.timer) clearTimeout(p.timer)
|
|
2969
3108
|
try { p.resolve('allow') } catch { /* noop */ }
|
|
2970
3109
|
s.pending.delete(key)
|
|
@@ -3614,17 +3753,29 @@ channel
|
|
|
3614
3753
|
.on('broadcast', { event: 'code-perm' }, ({ payload }) => {
|
|
3615
3754
|
const s = payload?.term && sessions.get(payload.term)
|
|
3616
3755
|
const p = s && payload.id && s.pending.get(payload.id)
|
|
3617
|
-
|
|
3618
|
-
//
|
|
3619
|
-
//
|
|
3620
|
-
//
|
|
3621
|
-
|
|
3756
|
+
if (!p) return
|
|
3757
|
+
// `tpcode` is a public transport. Treat the frame only as a wake-up hint and
|
|
3758
|
+
// reproduce its resolved item + unique delivery attempt through owner-authenticated
|
|
3759
|
+
// RLS reads before touching the local pending resolver. A forged broadcast then
|
|
3760
|
+
// has no more authority than packet noise.
|
|
3761
|
+
void fetchPairControlDeliveryAuthority({
|
|
3762
|
+
supabaseUrl: SUPABASE_URL,
|
|
3763
|
+
anonKey: SUPABASE_ANON,
|
|
3764
|
+
token: codeAuthToken,
|
|
3765
|
+
roomCode: room,
|
|
3766
|
+
bridgeId: BRIDGE_ID,
|
|
3767
|
+
payload,
|
|
3768
|
+
}).then((authority) => {
|
|
3769
|
+
if (!authority.ok || sessions.get(payload.term)?.pending.get(payload.id) !== p) {
|
|
3770
|
+
if (!authority.ok && process.env.TP_DEBUG) process.stderr.write(`\n ◇ ignored unverified permission delivery (${authority.code})\n`)
|
|
3771
|
+
return
|
|
3772
|
+
}
|
|
3622
3773
|
if (p.timer) clearTimeout(p.timer)
|
|
3623
3774
|
s.pending.delete(payload.id)
|
|
3624
3775
|
s.permNotifier?.resolve(payload.id)
|
|
3625
|
-
p.resolve(pendingResolution(p, payload))
|
|
3776
|
+
p.resolve(pendingResolution(p, { ...payload, decision: authority.decision }))
|
|
3626
3777
|
announce()
|
|
3627
|
-
}
|
|
3778
|
+
})
|
|
3628
3779
|
})
|
|
3629
3780
|
.on('broadcast', { event: 'code-abort' }, ({ payload }) => {
|
|
3630
3781
|
const s = payload?.term && sessions.get(payload.term)
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/* Canonical ThinkPool Code event contract.
|
|
2
|
+
Keep this module browser-safe: the bridge and src/pages/code both consume it. */
|
|
3
|
+
|
|
4
|
+
export const EVENT_CLASS = Object.freeze({
|
|
5
|
+
VOLATILE_PRESENCE: 'volatile-presence',
|
|
6
|
+
REPLAYABLE_PROGRESS: 'replayable-progress',
|
|
7
|
+
REPLAYABLE_COMMAND: 'replayable-command',
|
|
8
|
+
DURABLE_DELIVERY_EVIDENCE: 'durable-delivery-evidence',
|
|
9
|
+
UNKNOWN: 'unknown',
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
const V = EVENT_CLASS.VOLATILE_PRESENCE
|
|
13
|
+
const P = EVENT_CLASS.REPLAYABLE_PROGRESS
|
|
14
|
+
const C = EVENT_CLASS.REPLAYABLE_COMMAND
|
|
15
|
+
const E = EVENT_CLASS.DURABLE_DELIVERY_EVIDENCE
|
|
16
|
+
|
|
17
|
+
// Exhaustive over every kind currently emitted into a Code room, its retained log,
|
|
18
|
+
// or the durable __agent notification stream. `answer`/`control`/etc. are commands
|
|
19
|
+
// replayed by the transcript; they are not durable decision records. Foundation B
|
|
20
|
+
// will add actor-bound decision records and authoritative verification receipts.
|
|
21
|
+
// Adding a producer without adding its kind here is a test/dev error and a
|
|
22
|
+
// fail-closed production value.
|
|
23
|
+
export const CODE_EVENT_REGISTRY = Object.freeze({
|
|
24
|
+
system: V,
|
|
25
|
+
thinking_tokens: V,
|
|
26
|
+
effort: V,
|
|
27
|
+
mode: V,
|
|
28
|
+
usage: V,
|
|
29
|
+
suggestion: V,
|
|
30
|
+
stalled: V,
|
|
31
|
+
compact: V,
|
|
32
|
+
assistant_stream: V,
|
|
33
|
+
models: V,
|
|
34
|
+
'model-switch': V,
|
|
35
|
+
capabilities: V,
|
|
36
|
+
'update-available': V,
|
|
37
|
+
|
|
38
|
+
you: P,
|
|
39
|
+
assistant: P,
|
|
40
|
+
tool_result: P,
|
|
41
|
+
note: P,
|
|
42
|
+
pool: P,
|
|
43
|
+
chat: P,
|
|
44
|
+
whisper: P,
|
|
45
|
+
compaction: P,
|
|
46
|
+
'side-started': P,
|
|
47
|
+
'side-handback': P,
|
|
48
|
+
answer: C,
|
|
49
|
+
control: C,
|
|
50
|
+
clear: C,
|
|
51
|
+
'queue-add': C,
|
|
52
|
+
'queue-cancel': C,
|
|
53
|
+
propose: C,
|
|
54
|
+
'propose-done': C,
|
|
55
|
+
|
|
56
|
+
result: E,
|
|
57
|
+
error: E,
|
|
58
|
+
mockup: E,
|
|
59
|
+
image: E,
|
|
60
|
+
'needs-input': E,
|
|
61
|
+
'needs-resolved': E,
|
|
62
|
+
'turn-done': E,
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
export const CODE_EVENT_KINDS = Object.freeze(Object.keys(CODE_EVENT_REGISTRY))
|
|
66
|
+
|
|
67
|
+
const strictByDefault = () =>
|
|
68
|
+
typeof process !== 'undefined' && (process.env?.NODE_ENV === 'test' || process.env?.NODE_ENV === 'development')
|
|
69
|
+
|
|
70
|
+
export function classifyCodeEvent (eventOrKind, { strict = strictByDefault() } = {}) {
|
|
71
|
+
const kind = typeof eventOrKind === 'string' ? eventOrKind : eventOrKind?.kind
|
|
72
|
+
const eventClass = typeof kind === 'string' ? CODE_EVENT_REGISTRY[kind] : null
|
|
73
|
+
if (!eventClass) {
|
|
74
|
+
if (strict) throw new TypeError(`Unknown ThinkPool Code event kind: ${String(kind)}`)
|
|
75
|
+
return Object.freeze({ kind: kind ?? null, known: false, eventClass: EVENT_CLASS.UNKNOWN, durable: false, replayable: false, authorizes: false, completes: false })
|
|
76
|
+
}
|
|
77
|
+
return Object.freeze({
|
|
78
|
+
kind,
|
|
79
|
+
known: true,
|
|
80
|
+
eventClass,
|
|
81
|
+
// A replayable command is historical UI context, not a durable decision fact.
|
|
82
|
+
// Only delivery evidence currently has the archive-level durability contract.
|
|
83
|
+
durable: eventClass === E,
|
|
84
|
+
replayable: eventClass !== V,
|
|
85
|
+
// Foundation B supplies actor-bound authorization. Classification alone never does.
|
|
86
|
+
authorizes: false,
|
|
87
|
+
// Lifecycle completion additionally requires an explicit verification receipt.
|
|
88
|
+
completes: false,
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Preserve observability without copying an unregistered payload into the replay
|
|
93
|
+
// or archive. This is a bridge-observed delivery failure, not a claim about what
|
|
94
|
+
// the producer meant or a substitute for its event.
|
|
95
|
+
export function unknownCodeEventNotice (eventOrKind) {
|
|
96
|
+
const rawKind = typeof eventOrKind === 'string' ? eventOrKind : eventOrKind?.kind
|
|
97
|
+
const unknownKind = typeof rawKind === 'string' ? rawKind.slice(0, 120) : null
|
|
98
|
+
return {
|
|
99
|
+
kind: 'error',
|
|
100
|
+
subtype: 'unknown_event',
|
|
101
|
+
message: `Unrecognized Code event${unknownKind ? ` "${unknownKind}"` : ''} was rejected; this lane needs recovery.`,
|
|
102
|
+
unknownKind,
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export const isKnownCodeEventKind = (kind) => Object.hasOwn(CODE_EVENT_REGISTRY, kind)
|
package/cross-terminal.mjs
CHANGED
|
@@ -8,6 +8,9 @@
|
|
|
8
8
|
the structured-event compaction here mirrors that file's digestLine shape.
|
|
9
9
|
Spec: docs/specs/2026-06-28-cross-terminal-awareness.md */
|
|
10
10
|
|
|
11
|
+
import { createHash } from 'node:crypto'
|
|
12
|
+
import { BLOCKED_REASON, projectLaneLifecycle } from './lane-lifecycle.mjs'
|
|
13
|
+
|
|
11
14
|
// Bounds — the pull tool is self-limiting (the agent only calls it when it needs
|
|
12
15
|
// to), but a runaway loop must still be capped (the cost-guard / C10 class).
|
|
13
16
|
export const PEEK = {
|
|
@@ -72,7 +75,16 @@ export const laneStatusOf = (entry = {}, now = Date.now(), limits = LANE_STATUS)
|
|
|
72
75
|
statusFlag = 'BLOCKED'; statusReason = 'repeated errors'
|
|
73
76
|
}
|
|
74
77
|
|
|
75
|
-
|
|
78
|
+
const lifecycle = projectLaneLifecycle({
|
|
79
|
+
events: log,
|
|
80
|
+
busy: !!busy,
|
|
81
|
+
pendingCount,
|
|
82
|
+
pendingReason: BLOCKED_REASON.NEEDS_DECISION,
|
|
83
|
+
stalled: statusFlag === 'STUCK',
|
|
84
|
+
failed: failures >= limits.repeatedFailureCount && !busy,
|
|
85
|
+
rawStatus: statusFlag || (busy ? 'busy' : 'idle'),
|
|
86
|
+
})
|
|
87
|
+
return { busy: !!busy, lastActionAt, lastActionAgeMs, statusFlag, statusReason, lifecycle }
|
|
76
88
|
}
|
|
77
89
|
|
|
78
90
|
export const formatLaneAge = (ageMs) => {
|
|
@@ -86,7 +98,7 @@ export const formatLaneAge = (ageMs) => {
|
|
|
86
98
|
return `${Math.floor(h / 24)}d ago`
|
|
87
99
|
}
|
|
88
100
|
|
|
89
|
-
const laneStatusText = (x) => `${x.
|
|
101
|
+
const laneStatusText = (x) => `${x.lifecycle?.state || 'Blocked'}${x.lifecycle?.reason ? ` (${x.lifecycle.reason})` : ''} · ${formatLaneAge(x.lastActionAgeMs)}`
|
|
90
102
|
|
|
91
103
|
const clip = (s, cap) => {
|
|
92
104
|
const t = String(s == null ? '' : s).replace(/\s+/g, ' ').trim()
|
|
@@ -406,6 +418,133 @@ export const spawnDecision = ({ hop = 0, spawnTimes = [], now = 0, spawnedLive =
|
|
|
406
418
|
return { ok: true }
|
|
407
419
|
}
|
|
408
420
|
|
|
421
|
+
// ── Previewed Dispatch contract ────────────────────────────────────────────
|
|
422
|
+
// The durable pair-control row remains the approval authority. This contract
|
|
423
|
+
// supplies the immutable, secret-free object that row refers to and the bridge
|
|
424
|
+
// rechecks immediately before it spends a lane. No token, provider credential,
|
|
425
|
+
// host path, or mutable process object is copied into the preview.
|
|
426
|
+
const DISPATCH_ARG_KEYS = Object.freeze(['mode', 'model', 'name', 'provider', 'runtime', 'sliceType', 'task'])
|
|
427
|
+
const DISPATCH_MODES = new Set(['default', 'acceptEdits', 'bypassPermissions', 'plan'])
|
|
428
|
+
const DISPATCH_RUNTIMES = new Set(['claude', 'codex'])
|
|
429
|
+
const DISPATCH_SLICES = new Set(['scaffold', 'feature', 'fix', 'review'])
|
|
430
|
+
const secretValue = /((?<![a-z0-9])sk-[a-z0-9_-]{8,}|(?<![a-z0-9])gsk_[a-z0-9_-]{8,}|(?<![a-z0-9])AIza[a-z0-9_-]{8,}|bearer\s+[a-z0-9._-]{8,})/ig
|
|
431
|
+
const hostPath = /(?:\/home\/|\/users\/|\/private\/|\/tmp\/|[a-z]:\\|\\\\)/ig
|
|
432
|
+
const canonical = (value) => {
|
|
433
|
+
if (Array.isArray(value)) return value.map(canonical)
|
|
434
|
+
if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonical(value[key])]))
|
|
435
|
+
return value
|
|
436
|
+
}
|
|
437
|
+
const sha256 = (value) => createHash('sha256').update(JSON.stringify(canonical(value))).digest('hex')
|
|
438
|
+
const bounded = (value, max) => value == null ? null : String(value).slice(0, max)
|
|
439
|
+
const safeCopy = (value, max) => bounded(value, max)?.replace(secretValue, '[credential redacted]').replace(hostPath, '[host path redacted]') || ''
|
|
440
|
+
|
|
441
|
+
export function normalizeDispatchArgs (args = {}, inherited = {}) {
|
|
442
|
+
if (!args || typeof args !== 'object' || Array.isArray(args) || Object.keys(args).some((key) => !DISPATCH_ARG_KEYS.includes(key))) {
|
|
443
|
+
throw new TypeError('Invalid dispatch arguments')
|
|
444
|
+
}
|
|
445
|
+
const runtime = args.runtime || inherited.runtime || 'claude'
|
|
446
|
+
const mode = args.mode || (inherited.mode === 'plan' ? 'default' : inherited.mode) || 'default'
|
|
447
|
+
if (!DISPATCH_RUNTIMES.has(runtime) || !DISPATCH_MODES.has(mode) || (args.sliceType && !DISPATCH_SLICES.has(args.sliceType))) {
|
|
448
|
+
throw new TypeError('Invalid dispatch arguments')
|
|
449
|
+
}
|
|
450
|
+
return Object.freeze({
|
|
451
|
+
name: bounded(args.name, 80),
|
|
452
|
+
task: bounded(args.task, 12000),
|
|
453
|
+
model: bounded(args.model, 160),
|
|
454
|
+
sliceType: bounded(args.sliceType, 24),
|
|
455
|
+
runtime,
|
|
456
|
+
provider: bounded(args.provider, 160),
|
|
457
|
+
mode,
|
|
458
|
+
})
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
export function dispatchCapSnapshot ({ hop = 0, spawnTimes = [], now = 0, spawnedLive = 0, totalLive = 0, plan = 'free', disabled = false } = {}, limits = SPAWN) {
|
|
462
|
+
const o = { ...SPAWN, ...(limits || {}) }
|
|
463
|
+
const planName = plan === 'plus' ? 'plus' : 'free'
|
|
464
|
+
const planLimit = maxSpawnedFor(planName, o)
|
|
465
|
+
return Object.freeze({
|
|
466
|
+
killSwitch: disabled === true,
|
|
467
|
+
hop: Object.freeze({ used: Math.max(0, Number(hop) || 0), limit: o.maxHop }),
|
|
468
|
+
width: Object.freeze({ requested: 1, live: Math.max(0, Number(spawnedLive) || 0), limit: planLimit }),
|
|
469
|
+
machine: Object.freeze({ live: Math.max(0, Number(totalLive) || 0), limit: o.machineMax }),
|
|
470
|
+
plan: Object.freeze({ name: planName, price: planName === 'plus' ? '€5/mo' : '€0', used: Math.max(0, Number(spawnedLive) || 0), limit: planLimit }),
|
|
471
|
+
burst: Object.freeze({ used: recentSpawnCount(spawnTimes, now, o.windowMs), limit: o.perWindowCap, windowMinutes: Math.round(o.windowMs / 60000) }),
|
|
472
|
+
})
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
export function buildDispatchPreview ({ args, initiatorTerminalId, roomCode, bridgeAuthorityId, localAuthorityId, context = {}, limits = SPAWN } = {}) {
|
|
476
|
+
if (!initiatorTerminalId || !roomCode || !bridgeAuthorityId || !localAuthorityId) throw new TypeError('Dispatch authority is incomplete')
|
|
477
|
+
const exactArgs = normalizeDispatchArgs(args, { runtime: context.runtime, mode: context.mode })
|
|
478
|
+
const caps = dispatchCapSnapshot(context, limits)
|
|
479
|
+
// The terminal initiates the request; it is NOT the human approver. The human
|
|
480
|
+
// actor is bound later by code_pair_control_items.resolved_by plus the matching
|
|
481
|
+
// durable resolution/delivery-attempt events.
|
|
482
|
+
const authority = Object.freeze({ initiatorTerminalId: String(initiatorTerminalId), roomCode: String(roomCode).toUpperCase(), bridgeAuthorityId: String(bridgeAuthorityId), localAuthorityId: String(localAuthorityId) })
|
|
483
|
+
const fingerprint = sha256({ authority, exactArgs, caps })
|
|
484
|
+
const provider = exactArgs.provider || (exactArgs.runtime === 'codex' ? 'OpenAI / Codex login' : 'Anthropic / host default')
|
|
485
|
+
const model = exactArgs.model || context.resolvedModel || 'runtime default'
|
|
486
|
+
const purpose = safeCopy(exactArgs.name || exactArgs.task?.split(/\r?\n/)[0] || 'Open one worker lane', 120)
|
|
487
|
+
return Object.freeze({
|
|
488
|
+
version: 1,
|
|
489
|
+
fingerprint,
|
|
490
|
+
purpose,
|
|
491
|
+
packages: Object.freeze([{ title: safeCopy(exactArgs.name || 'Worker package', 80), task: safeCopy(exactArgs.task || 'Open idle for a later hand-off.', 600) }]),
|
|
492
|
+
runtime: Object.freeze({
|
|
493
|
+
name: exactArgs.runtime,
|
|
494
|
+
provider: safeCopy(context.providerName || provider, 120),
|
|
495
|
+
model: safeCopy(model, 160),
|
|
496
|
+
band: 'Start: now–1 min · completion: task-dependent',
|
|
497
|
+
execution: 'Runs locally on the bridge machine; no cloud worker is created.',
|
|
498
|
+
accountAndSpend: `Uses the host’s ${safeCopy(context.providerName || provider, 100)} account/BYOK. Provider usage is the spend source; ThinkPool has no credit meter.`,
|
|
499
|
+
}),
|
|
500
|
+
permissions: Object.freeze({ mode: exactArgs.mode, consequence: exactArgs.mode === 'bypassPermissions'
|
|
501
|
+
? 'Unattended local access within this worker’s sandbox and tool policy.'
|
|
502
|
+
: exactArgs.mode === 'plan' ? 'Planning only; execution remains gated.' : 'Protected local actions keep their normal permission gates.' }),
|
|
503
|
+
worktree: 'Automatically creates one visible lateral worker lane and an isolated git worktree on this machine.',
|
|
504
|
+
caps,
|
|
505
|
+
})
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
export function authorizeDispatchApproval ({ preview, approvedFingerprint, args, initiatorTerminalId, roomCode, bridgeAuthorityId, localAuthorityId, context = {}, decision = 'allow', consumed = false, permissionId = null, consumedPermissionId = null, limits = SPAWN } = {}) {
|
|
509
|
+
if (decision !== 'allow') return { ok: false, code: 'dispatch_canceled' }
|
|
510
|
+
if (consumed) return { ok: false, code: permissionId && permissionId === consumedPermissionId ? 'idempotent_replay' : 'approval_replayed' }
|
|
511
|
+
let current
|
|
512
|
+
try { current = buildDispatchPreview({ args, initiatorTerminalId, roomCode, bridgeAuthorityId, localAuthorityId, context, limits }) } catch { return { ok: false, code: 'invalid_dispatch' } }
|
|
513
|
+
if (!preview || preview.fingerprint !== approvedFingerprint) return { ok: false, code: 'approval_mismatch' }
|
|
514
|
+
if (current.fingerprint !== preview.fingerprint) return { ok: false, code: 'stale_dispatch' }
|
|
515
|
+
const gate = spawnDecision(context, limits)
|
|
516
|
+
if (!gate.ok) return { ok: false, code: 'cap_denied', reason: gate.reason }
|
|
517
|
+
return { ok: true, code: 'approved', preview: current }
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// A public Realtime broadcast is only a wake-up hint. This verifier consumes the
|
|
521
|
+
// rows the bridge re-read through its authenticated room-member token. Exactly one
|
|
522
|
+
// durable winner (resolved_by) must own both the resolution and the single delivery
|
|
523
|
+
// attempt, and every immutable authority/fingerprint field must match.
|
|
524
|
+
export function verifyDurableDispatchAuthority ({ item, events = [], expected = {} } = {}) {
|
|
525
|
+
const fail = (code) => ({ ok: false, code })
|
|
526
|
+
const fp = String(expected.fingerprint || '')
|
|
527
|
+
if (!item || item.id !== expected.controlItemId) return fail('control_item_mismatch')
|
|
528
|
+
if (item.session_code !== String(expected.roomCode || '').toUpperCase()) return fail('wrong_room')
|
|
529
|
+
if (item.action_kind !== 'dispatch' || item.item_kind !== 'approval') return fail('wrong_action')
|
|
530
|
+
if (item.status !== 'resolved' || item.resolution_code !== 'approved' || !item.resolved_by) return fail('not_approved')
|
|
531
|
+
if (item.resolution_policy !== 'either_member' || item.required_actor_id != null) return fail('wrong_resolution_policy')
|
|
532
|
+
if (item.bridge_authority_id !== expected.bridgeAuthorityId) return fail('wrong_bridge_authority')
|
|
533
|
+
if (item.local_authority_id !== expected.localAuthorityId) return fail('wrong_local_authority')
|
|
534
|
+
if (!/^[0-9a-f]{64}$/.test(fp)) return fail('invalid_fingerprint')
|
|
535
|
+
if (!String(item.request_context?.action || '').includes(`fp:${fp}`)) return fail('request_fingerprint_mismatch')
|
|
536
|
+
if (item.resolution_payload?.dispatch_fingerprint !== fp) return fail('resolution_fingerprint_mismatch')
|
|
537
|
+
const relevant = (Array.isArray(events) ? events : []).filter((event) => event?.item_id === item.id)
|
|
538
|
+
const resolutions = relevant.filter((event) => event.event_type === 'resolved' && event.actor_id === item.resolved_by &&
|
|
539
|
+
event.payload?.resolution_payload?.dispatch_fingerprint === fp && event.payload?.resolution_code === 'approved')
|
|
540
|
+
if (resolutions.length !== 1) return fail('resolution_event_mismatch')
|
|
541
|
+
const attempts = relevant.filter((event) => event.event_type === 'delivery_attempted' && event.actor_id === item.resolved_by &&
|
|
542
|
+
event.payload?.data?.dispatch_fingerprint === fp)
|
|
543
|
+
if (attempts.length !== 1) return fail('delivery_attempt_mismatch')
|
|
544
|
+
if (relevant.some((event) => event.event_type === 'delivered' || event.event_type === 'delivery_failed')) return fail('delivery_already_final')
|
|
545
|
+
return { ok: true, code: 'durable_dispatch_authorized', fingerprint: fp, humanActorId: item.resolved_by, attemptKey: attempts[0].correlation_key }
|
|
546
|
+
}
|
|
547
|
+
|
|
409
548
|
// When a spawn is refused, "close one before opening another" is useless advice if the
|
|
410
549
|
// caller has to go hunting for WHICH one. Rooms fill up with finished lanes nobody
|
|
411
550
|
// closed (Max, 2026-07-10: "clean up the finished ensemble terminals there's a lot"),
|
package/event-id.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto'
|
|
2
2
|
import { boundStructuredEvent, STRUCTURED_EVENT_CAP } from './event-bounds.mjs'
|
|
3
|
+
import { classifyCodeEvent } from './code-event-contract.mjs'
|
|
3
4
|
|
|
4
5
|
/* event-id.mjs — stamp a structured event with a stable identity + timestamp
|
|
5
6
|
BEFORE it is logged + broadcast.
|
|
@@ -61,8 +62,11 @@ export function makeSeqCounter(startAfter = 0) {
|
|
|
61
62
|
// consumed a seq + set the durable archive floor while the client dropped them, leaving
|
|
62
63
|
// the client's oldest RENDERED seq above the floor → a phantom "load earlier history"
|
|
63
64
|
// pill on a fresh lane (models fires on every session init) that fetched nothing.
|
|
64
|
-
export const NO_SEQ_KINDS = new Set(['system', 'thinking_tokens', 'effort', 'models', '
|
|
65
|
-
export const seqable = (evt) =>
|
|
65
|
+
export const NO_SEQ_KINDS = new Set(['system', 'thinking_tokens', 'effort', 'models', 'queue-add', 'queue-cancel', 'propose-done', 'update-available'])
|
|
66
|
+
export const seqable = (evt) => {
|
|
67
|
+
if (!evt || typeof evt !== 'object') return false
|
|
68
|
+
return classifyCodeEvent(evt, { strict: false }).known && !NO_SEQ_KINDS.has(evt.kind)
|
|
69
|
+
}
|
|
66
70
|
|
|
67
71
|
// Highest numeric `seq` across a log of events (0 when none) — the floor a restored
|
|
68
72
|
// session's counter resumes above.
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { classifyCodeEvent } from './code-event-contract.mjs'
|
|
2
|
+
|
|
3
|
+
// This file ships in the standalone thinkpool-pair tarball. Keep the canonical
|
|
4
|
+
// vocabulary here rather than importing from ../src (which is not packaged).
|
|
5
|
+
// The browser-side module re-exports these constants from this file.
|
|
6
|
+
export const LIFECYCLE_STATE = Object.freeze({
|
|
7
|
+
WORKING: 'Working', BLOCKED: 'Blocked', FAILED: 'Failed', DONE: 'Done', CANCELED: 'Canceled',
|
|
8
|
+
})
|
|
9
|
+
export const BLOCKED_REASON = Object.freeze({
|
|
10
|
+
NEEDS_DECISION: 'needs_decision', NEEDS_INPUT: 'needs_input', DEPENDENCY: 'dependency',
|
|
11
|
+
OFFLINE: 'offline', STALLED: 'stalled', UNKNOWN_GAP: 'unknown_gap',
|
|
12
|
+
VERIFICATION_REQUIRED: 'verification_required',
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
const TERMINAL = new Set([LIFECYCLE_STATE.FAILED, LIFECYCLE_STATE.DONE, LIFECYCLE_STATE.CANCELED])
|
|
16
|
+
const RESULT_CANCELED = new Set(['aborted', 'cancelled', 'canceled', 'interrupted'])
|
|
17
|
+
|
|
18
|
+
export function canTransitionLifecycle (from, to, { beginsNewWork = false } = {}) {
|
|
19
|
+
if (!from || from === to) return true
|
|
20
|
+
if (TERMINAL.has(from)) return beginsNewWork && to === LIFECYCLE_STATE.WORKING
|
|
21
|
+
return from === LIFECYCLE_STATE.WORKING || from === LIFECYCLE_STATE.BLOCKED
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function transitionLifecycle (current, next, opts = {}) {
|
|
25
|
+
if (!canTransitionLifecycle(current?.state, next?.state, opts)) {
|
|
26
|
+
if (opts.strict !== false) throw new TypeError(`Illegal lifecycle transition: ${current?.state} -> ${next?.state}`)
|
|
27
|
+
return current
|
|
28
|
+
}
|
|
29
|
+
return { ...next, rawStatus: next.rawStatus ?? current?.rawStatus ?? null }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// JSON.stringify's array replacer applies at every nesting level. The previous
|
|
33
|
+
// implementation therefore discarded nested assistant blocks/payload fields and
|
|
34
|
+
// could treat two conflicting events as identical. Sort every object recursively
|
|
35
|
+
// so replay identity compares the complete event, not a top-level silhouette.
|
|
36
|
+
const stableEventShape = (event) => JSON.stringify(event, (_key, value) => {
|
|
37
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return value
|
|
38
|
+
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, value[key]]))
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
// Replay identity is two-dimensional: cid identifies the optimistic/live event and
|
|
42
|
+
// seq identifies its durable position. A conflict in either dimension is evidence
|
|
43
|
+
// of a split/restarted stream, never a reason to choose whichever array order arrived.
|
|
44
|
+
const normalizeReplayEvents = (events) => {
|
|
45
|
+
const input = Array.isArray(events) ? events : []
|
|
46
|
+
const allSequenced = input.every((event) => Number.isFinite(event?.seq))
|
|
47
|
+
const ordered = input
|
|
48
|
+
.map((event, sourceIndex) => ({ event, shape: stableEventShape(event), sourceIndex }))
|
|
49
|
+
.sort((a, b) => {
|
|
50
|
+
// Old/current optimistic transcript events can legitimately have no seq.
|
|
51
|
+
// Their array position is the only boundary order; lexical kind sorting
|
|
52
|
+
// can move a `you` event after its aborted `result` and resurrect a turn.
|
|
53
|
+
if (!allSequenced) return a.sourceIndex - b.sourceIndex
|
|
54
|
+
if (a.event.seq !== b.event.seq) return a.event.seq - b.event.seq
|
|
55
|
+
const at = Number(a.event?.ts) || 0, bt = Number(b.event?.ts) || 0
|
|
56
|
+
if (at !== bt) return at - bt
|
|
57
|
+
return String(a.event?.cid || '').localeCompare(String(b.event?.cid || '')) || a.shape.localeCompare(b.shape)
|
|
58
|
+
})
|
|
59
|
+
const cids = new Map()
|
|
60
|
+
const seqs = new Map()
|
|
61
|
+
const out = []
|
|
62
|
+
const conflicts = []
|
|
63
|
+
for (const item of ordered) {
|
|
64
|
+
const { event, shape } = item
|
|
65
|
+
const cid = typeof event?.cid === 'string' && event.cid ? event.cid : null
|
|
66
|
+
const seq = Number.isFinite(event?.seq) ? event.seq : null
|
|
67
|
+
const cidPrior = cid ? cids.get(cid) : null
|
|
68
|
+
const seqPrior = seq != null ? seqs.get(seq) : null
|
|
69
|
+
if (cidPrior || seqPrior) {
|
|
70
|
+
const prior = cidPrior || seqPrior
|
|
71
|
+
if (prior.shape !== shape) conflicts.push({ cid, seq, priorCid: prior.cid, priorSeq: prior.seq })
|
|
72
|
+
continue
|
|
73
|
+
}
|
|
74
|
+
const record = { cid, seq, shape }
|
|
75
|
+
if (cid) cids.set(cid, record)
|
|
76
|
+
if (seq != null) seqs.set(seq, record)
|
|
77
|
+
out.push(event)
|
|
78
|
+
}
|
|
79
|
+
return { events: out, conflicts }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const base = (state, reason = null, extra = {}) => ({ state, reason, phase: null, freshness: 'current', transport: 'online', rawStatus: null, verified: false, ...extra })
|
|
83
|
+
|
|
84
|
+
export function projectLaneLifecycle ({
|
|
85
|
+
events = [], busy = false, pendingCount = 0, pendingReason = null,
|
|
86
|
+
stalled = false, offline = false, stale = false, failed = false, canceled = false,
|
|
87
|
+
rawStatus = null,
|
|
88
|
+
} = {}) {
|
|
89
|
+
let out = base(LIFECYCLE_STATE.BLOCKED, BLOCKED_REASON.NEEDS_INPUT, { rawStatus })
|
|
90
|
+
const replay = normalizeReplayEvents(events)
|
|
91
|
+
let unknownEvent = false
|
|
92
|
+
for (const event of replay.events) {
|
|
93
|
+
const contract = classifyCodeEvent(event, { strict: false })
|
|
94
|
+
if (!contract.known) { unknownEvent = true; continue }
|
|
95
|
+
if (contract.eventClass === 'volatile-presence') continue
|
|
96
|
+
if (event.kind === 'pool' && (event.transient || event.pending)) {
|
|
97
|
+
continue
|
|
98
|
+
}
|
|
99
|
+
if (event.kind === 'you' || event.kind === 'assistant' || event.kind === 'tool_result' || event.kind === 'pool') {
|
|
100
|
+
out = base(LIFECYCLE_STATE.WORKING, null, { rawStatus, phase: event.kind })
|
|
101
|
+
} else if (event.kind === 'propose' || event.kind === 'answer') {
|
|
102
|
+
out = base(LIFECYCLE_STATE.BLOCKED, BLOCKED_REASON.NEEDS_DECISION, { rawStatus })
|
|
103
|
+
} else if (event.kind === 'needs-input') {
|
|
104
|
+
out = base(LIFECYCLE_STATE.BLOCKED, BLOCKED_REASON.NEEDS_INPUT, { rawStatus })
|
|
105
|
+
} else if (event.kind === 'error') {
|
|
106
|
+
out = base(LIFECYCLE_STATE.FAILED, null, { rawStatus })
|
|
107
|
+
} else if (event.kind === 'result') {
|
|
108
|
+
if (RESULT_CANCELED.has(event.subtype)) out = base(LIFECYCLE_STATE.CANCELED, null, { rawStatus })
|
|
109
|
+
else if (event.subtype && event.subtype !== 'success') out = base(LIFECYCLE_STATE.FAILED, null, { rawStatus })
|
|
110
|
+
// An agent-supplied `verified` field (or a broad caller flag) is only a
|
|
111
|
+
// claim. Foundation B will wire an authority-bound receipt; Foundation A
|
|
112
|
+
// deliberately cannot make a lane Done from transcript evidence alone.
|
|
113
|
+
else out = base(LIFECYCLE_STATE.BLOCKED, BLOCKED_REASON.VERIFICATION_REQUIRED, { rawStatus })
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (replay.conflicts.length || unknownEvent) out = base(LIFECYCLE_STATE.BLOCKED, BLOCKED_REASON.UNKNOWN_GAP, { rawStatus })
|
|
118
|
+
else if (failed) out = base(LIFECYCLE_STATE.FAILED, null, { rawStatus })
|
|
119
|
+
else if (canceled) out = base(LIFECYCLE_STATE.CANCELED, null, { rawStatus })
|
|
120
|
+
else if (pendingCount > 0) out = base(LIFECYCLE_STATE.BLOCKED, pendingReason || BLOCKED_REASON.NEEDS_DECISION, { rawStatus })
|
|
121
|
+
else if (stalled) out = base(LIFECYCLE_STATE.BLOCKED, BLOCKED_REASON.STALLED, { rawStatus, phase: 'stalled' })
|
|
122
|
+
else if (busy && !TERMINAL.has(out.state)) out = base(LIFECYCLE_STATE.WORKING, null, { rawStatus, phase: out.phase || 'active' })
|
|
123
|
+
else if (offline && !TERMINAL.has(out.state)) out = base(LIFECYCLE_STATE.BLOCKED, BLOCKED_REASON.OFFLINE, { rawStatus })
|
|
124
|
+
|
|
125
|
+
if (offline) out.transport = 'offline'
|
|
126
|
+
if (stale) out.freshness = 'stale'
|
|
127
|
+
return Object.freeze(out)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function projectFlowLifecycle (rawStatus) {
|
|
131
|
+
switch (rawStatus) {
|
|
132
|
+
case 'building': case 'dispatched': case 'reviewing': case 'assembling': case 'planning':
|
|
133
|
+
return base(LIFECYCLE_STATE.WORKING, null, { rawStatus, phase: rawStatus })
|
|
134
|
+
case 'awaiting_approval': case 'approved':
|
|
135
|
+
return base(LIFECYCLE_STATE.BLOCKED, BLOCKED_REASON.NEEDS_DECISION, { rawStatus })
|
|
136
|
+
case 'pending': case 'ready': case 'reverted':
|
|
137
|
+
return base(LIFECYCLE_STATE.BLOCKED, BLOCKED_REASON.DEPENDENCY, { rawStatus })
|
|
138
|
+
case 'failed': return base(LIFECYCLE_STATE.FAILED, null, { rawStatus })
|
|
139
|
+
case 'cancelled': case 'canceled': return base(LIFECYCLE_STATE.CANCELED, null, { rawStatus })
|
|
140
|
+
// A task's commit and an assembled artifact are evidence, not an observed
|
|
141
|
+
// verification receipt. No caller-provided boolean can promote Flow to Done.
|
|
142
|
+
case 'done': return base(LIFECYCLE_STATE.BLOCKED, BLOCKED_REASON.VERIFICATION_REQUIRED, { rawStatus })
|
|
143
|
+
default: return base(LIFECYCLE_STATE.BLOCKED, BLOCKED_REASON.NEEDS_INPUT, { rawStatus })
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function projectRecoveryWatermark ({ events = [], epoch = 0, floor = null, gap = null, offline = false, stale = false } = {}) {
|
|
148
|
+
const replay = normalizeReplayEvents(events)
|
|
149
|
+
const foreignEpoch = replay.events.some((e) => Number.isFinite(e?.epoch) && e.epoch !== epoch)
|
|
150
|
+
const seqEvents = replay.events.filter((e) => Number.isFinite(e?.seq) && (!Number.isFinite(e?.epoch) || e.epoch === epoch))
|
|
151
|
+
const last = seqEvents.reduce((best, e) => !best || e.seq > best.seq ? e : best, null)
|
|
152
|
+
let unknownGap = gap && Number.isFinite(gap.from) && Number.isFinite(gap.to)
|
|
153
|
+
? { from: gap.from, to: gap.to, reason: gap.reason || 'unknown', recoverable: gap.recoverable !== false }
|
|
154
|
+
: null
|
|
155
|
+
if (!unknownGap && (replay.conflicts.length || foreignEpoch)) {
|
|
156
|
+
const seqs = replay.conflicts.map((c) => c.seq).filter(Number.isFinite)
|
|
157
|
+
unknownGap = {
|
|
158
|
+
from: seqs.length ? Math.min(...seqs) : null,
|
|
159
|
+
to: seqs.length ? Math.max(...seqs) : null,
|
|
160
|
+
reason: replay.conflicts.length ? 'conflicting_event_identity' : 'foreign_epoch',
|
|
161
|
+
recoverable: true,
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (!unknownGap && seqEvents.length > 1) {
|
|
165
|
+
for (let i = 1; i < seqEvents.length; i++) {
|
|
166
|
+
if (seqEvents[i].seq > seqEvents[i - 1].seq + 1) {
|
|
167
|
+
unknownGap = { from: seqEvents[i - 1].seq + 1, to: seqEvents[i].seq - 1, reason: 'out_of_order_or_trimmed', recoverable: true }
|
|
168
|
+
break
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return Object.freeze({
|
|
173
|
+
epoch,
|
|
174
|
+
floor: Number.isFinite(floor) ? floor : null,
|
|
175
|
+
lastProven: last ? { cid: last.cid || null, seq: last.seq, epoch } : null,
|
|
176
|
+
unknownGap,
|
|
177
|
+
freshness: stale ? 'stale' : 'current',
|
|
178
|
+
transport: offline ? 'offline' : 'online',
|
|
179
|
+
globalRollbackAvailable: false,
|
|
180
|
+
})
|
|
181
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "thinkpool-pair",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.244",
|
|
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": {
|
|
@@ -33,6 +33,8 @@
|
|
|
33
33
|
"update-gate.mjs",
|
|
34
34
|
"agent-notify.mjs",
|
|
35
35
|
"agent-detect.mjs",
|
|
36
|
+
"code-event-contract.mjs",
|
|
37
|
+
"pair-control-authority.mjs",
|
|
36
38
|
"event-id.mjs",
|
|
37
39
|
"event-bounds.mjs",
|
|
38
40
|
"plan-meters.mjs",
|
|
@@ -41,6 +43,7 @@
|
|
|
41
43
|
"session-store.mjs",
|
|
42
44
|
"side-lane.mjs",
|
|
43
45
|
"cross-terminal.mjs",
|
|
46
|
+
"lane-lifecycle.mjs",
|
|
44
47
|
"interrupted-resume.mjs",
|
|
45
48
|
"flow-conductor.mjs",
|
|
46
49
|
"flow-worktree.mjs",
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Durable authority check for the bridge's public `code-perm` transport.
|
|
2
|
+
// Realtime is only a wake-up hint: the owner-authenticated REST reads below
|
|
3
|
+
// must reproduce the resolved item and its unique delivery-attempt record before
|
|
4
|
+
// a local pending action is allowed to move.
|
|
5
|
+
|
|
6
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
|
7
|
+
const SAFE_KEY = /^[A-Za-z0-9][A-Za-z0-9._:@-]{7,159}$/
|
|
8
|
+
const FINAL_STATUS = new Set(['resolved', 'denied'])
|
|
9
|
+
|
|
10
|
+
const denied = (code) => Object.freeze({ ok: false, code })
|
|
11
|
+
|
|
12
|
+
export function verifyPairControlDeliveryAuthority ({ item, attempts, payload, roomCode, bridgeId } = {}) {
|
|
13
|
+
if (!item || !payload || !UUID.test(String(payload.controlItemId || '')) ||
|
|
14
|
+
!SAFE_KEY.test(String(payload.attemptKey || '')) ||
|
|
15
|
+
!Number.isSafeInteger(payload.controlVersion) || payload.controlVersion < 1) {
|
|
16
|
+
return denied('invalid_delivery_hint')
|
|
17
|
+
}
|
|
18
|
+
const context = item.request_context
|
|
19
|
+
const resolution = item.resolution_payload
|
|
20
|
+
if (item.id !== payload.controlItemId || item.session_code !== String(roomCode || '').toUpperCase() ||
|
|
21
|
+
item.item_kind !== 'approval' || !['approval', 'dispatch'].includes(item.action_kind) ||
|
|
22
|
+
item.resolution_policy !== 'either_member' || item.required_actor_id != null ||
|
|
23
|
+
!FINAL_STATUS.has(item.status) || Number(item.version) !== payload.controlVersion ||
|
|
24
|
+
item.bridge_authority_id !== bridgeId || item.local_authority_id !== payload.term ||
|
|
25
|
+
!context || context.permission_id !== payload.id || context.terminal_ref !== payload.term ||
|
|
26
|
+
!resolution || resolution.decision !== payload.decision || !item.resolved_by) {
|
|
27
|
+
return denied('authority_mismatch')
|
|
28
|
+
}
|
|
29
|
+
if ((item.status === 'resolved' && !['approved', 'answered'].includes(item.resolution_code)) ||
|
|
30
|
+
(item.status === 'denied' && item.resolution_code !== 'rejected')) {
|
|
31
|
+
return denied('resolution_mismatch')
|
|
32
|
+
}
|
|
33
|
+
const matching = Array.isArray(attempts) ? attempts.filter((event) =>
|
|
34
|
+
event?.actor_id === item.resolved_by && Number(event.item_version) === Number(item.version) &&
|
|
35
|
+
event.correlation_key === payload.attemptKey && event.payload?.data?.transport === 'code-perm') : []
|
|
36
|
+
if (matching.length !== 1) return denied('delivery_attempt_unverified')
|
|
37
|
+
return Object.freeze({
|
|
38
|
+
ok: true,
|
|
39
|
+
code: 'durable_delivery_authorized',
|
|
40
|
+
decision: resolution.decision,
|
|
41
|
+
itemId: item.id,
|
|
42
|
+
version: Number(item.version),
|
|
43
|
+
attemptKey: payload.attemptKey,
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function fetchPairControlDeliveryAuthority ({
|
|
48
|
+
fetchImpl = globalThis.fetch, supabaseUrl, anonKey, token, roomCode, bridgeId, payload,
|
|
49
|
+
} = {}) {
|
|
50
|
+
if (typeof fetchImpl !== 'function' || !supabaseUrl || !anonKey || !token || !roomCode || !bridgeId ||
|
|
51
|
+
!UUID.test(String(payload?.controlItemId || '')) || !SAFE_KEY.test(String(payload?.attemptKey || ''))) {
|
|
52
|
+
return denied('authority_unavailable')
|
|
53
|
+
}
|
|
54
|
+
const headers = { apikey: anonKey, Authorization: `Bearer ${token}` }
|
|
55
|
+
const itemUrl = new URL('/rest/v1/code_pair_control_items', supabaseUrl)
|
|
56
|
+
itemUrl.searchParams.set('id', `eq.${payload.controlItemId}`)
|
|
57
|
+
itemUrl.searchParams.set('session_code', `eq.${String(roomCode).toUpperCase()}`)
|
|
58
|
+
itemUrl.searchParams.set('select', 'id,session_code,item_kind,action_kind,resolution_policy,required_actor_id,status,version,resolution_code,resolution_payload,resolved_by,bridge_authority_id,local_authority_id,request_context')
|
|
59
|
+
const eventUrl = new URL('/rest/v1/code_pair_control_events', supabaseUrl)
|
|
60
|
+
eventUrl.searchParams.set('item_id', `eq.${payload.controlItemId}`)
|
|
61
|
+
eventUrl.searchParams.set('event_type', 'eq.delivery_attempted')
|
|
62
|
+
eventUrl.searchParams.set('correlation_key', `eq.${payload.attemptKey}`)
|
|
63
|
+
eventUrl.searchParams.set('select', 'actor_id,item_version,correlation_key,payload')
|
|
64
|
+
eventUrl.searchParams.set('limit', '2')
|
|
65
|
+
try {
|
|
66
|
+
const [itemResponse, eventResponse] = await Promise.all([
|
|
67
|
+
fetchImpl(itemUrl, { headers }), fetchImpl(eventUrl, { headers }),
|
|
68
|
+
])
|
|
69
|
+
if (!itemResponse.ok || !eventResponse.ok) return denied('authority_read_failed')
|
|
70
|
+
const [items, attempts] = await Promise.all([itemResponse.json(), eventResponse.json()])
|
|
71
|
+
if (!Array.isArray(items) || items.length !== 1 || !Array.isArray(attempts)) return denied('authority_read_invalid')
|
|
72
|
+
return verifyPairControlDeliveryAuthority({ item: items[0], attempts, payload, roomCode, bridgeId })
|
|
73
|
+
} catch {
|
|
74
|
+
return denied('authority_read_failed')
|
|
75
|
+
}
|
|
76
|
+
}
|