thinkpool-pair 0.7.242 → 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 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, 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 gate = spawnDecision({
2284
- hop: entry.hop || 0,
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.
@@ -2296,11 +2370,65 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2296
2370
  ref: String(sid).slice(0, 8),
2297
2371
  name: termNames[sid] || undefined,
2298
2372
  spawnedBy: s.spawnedBy,
2299
- busy: s.session?.turnActive ?? false,
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 > now - SPAWN.windowMs), now]
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)
@@ -2376,7 +2504,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2376
2504
  // the web's code-close), which no-ops if the id isn't a live session.
2377
2505
  ...(!entry.flowRole ? [tool(
2378
2506
  'close_terminal',
2379
- 'Close an agent lane that YOU opened with spawn_terminal (identified by ref/id/name). You can only close lanes you spawned yourself — never a sibling someone else is working in, and never the main terminal. Use it to clean up after a fan-out once you have collected the results with read_terminal.',
2507
+ 'Close an IDLE agent lane that YOU opened with spawn_terminal (identified by ref/id/name). Active turns and permission waits are protected: collect the completed result with read_terminal before closing. You can only close lanes you spawned yourself — never a sibling someone else is working in, and never the main terminal.',
2380
2508
  {
2381
2509
  terminal: z.string().describe('ref, id, or name of the lane you spawned (from read_terminal)'),
2382
2510
  },
@@ -2387,7 +2515,15 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2387
2515
  if (!target) return okText(`No terminal matches "${args?.terminal}". Call read_terminal with no argument to list the open terminals.`)
2388
2516
  const te = sessions.get(target.id)
2389
2517
  if (!te) return okText(`Terminal ${target.ref} is not an agent lane (or already closed) — only spawned agent lanes can be closed this way.`)
2390
- if (te.spawnedBy !== id) return okText(`Terminal ${target.ref} was not opened by you — you can only close lanes you spawned yourself. Leave it to the person running it.`)
2518
+ const close = closeLaneDecision({
2519
+ callerId: id,
2520
+ spawnedBy: te.spawnedBy,
2521
+ busy: (te.session?.turnActive ?? false) || (te.pending?.size ?? 0) > 0,
2522
+ })
2523
+ if (!close.ok && close.reason === 'not-owner') return okText(`Terminal ${target.ref} was not opened by you — you can only close lanes you spawned yourself. Leave it to the person running it.`)
2524
+ if (!close.ok && close.reason === 'busy') {
2525
+ return okText(`Terminal ${target.ref} is still working or waiting for a permission decision. close_terminal will not kill an active turn. Wait until read_terminal reports it idle/completed, collect its result, then close it. If it is genuinely stuck, ask a person to press Stop.`)
2526
+ }
2391
2527
  endStructured(target.id)
2392
2528
  return okText(`Closed agent lane ${target.ref}${target.name ? ` ("${target.name}")` : ''}.`)
2393
2529
  },
@@ -2562,6 +2698,15 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2562
2698
  // default Claude env is left exactly as-is (unchanged path).
2563
2699
  env: { ...process.env, ...buildConductorEnv({ flowSessionId, mode }), ...(resolveProviderEnv(provider) || {}), TP_MOCKUP_OUTBOX: mockupOutbox },
2564
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
+ }
2565
2710
  // Self-heal a stale resume — the saved SDK session expired. Reopen fresh,
2566
2711
  // keeping the transcript (scrollback survives; live context is gone).
2567
2712
  if (resume && evt.kind === 'error') {
@@ -2861,7 +3006,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2861
3006
  }
2862
3007
  // Keep the broadcast payload with the resolver so a reconnect can re-send it
2863
3008
  // (replay-request handler) — pending cards never enter the replayed event log.
2864
- 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 })
2865
3010
  const pending = { resolve, payload, timer: null }
2866
3011
  entry.pending.set(req.id, pending)
2867
3012
  announce()
@@ -2926,7 +3071,9 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2926
3071
  // hook already degrades to, so draining to deny is always safe.
2927
3072
  function pendingResolution(pending, payload = {}) {
2928
3073
  const decision = payload.decision || 'deny'
2929
- return pending?.payload?.answerFormat === 'codex'
3074
+ return pending?.payload?.answerFormat === 'dispatch'
3075
+ ? { decision, dispatchFingerprint: payload.dispatchFingerprint || null, controlItemId: payload.controlItemId || null }
3076
+ : pending?.payload?.answerFormat === 'codex'
2930
3077
  ? { decision, answers: payload.answers || {} }
2931
3078
  : decision
2932
3079
  }
@@ -2956,7 +3103,7 @@ function allowPending(s) {
2956
3103
  // AskUserQuestion (risk:'ask') is a deliberate human gate — flipping to bypass means
2957
3104
  // "run tools without asking", not "auto-approve my plan / answer my question" — leave
2958
3105
  // those for the human.
2959
- 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
2960
3107
  if (p?.timer) clearTimeout(p.timer)
2961
3108
  try { p.resolve('allow') } catch { /* noop */ }
2962
3109
  s.pending.delete(key)
@@ -3606,17 +3753,29 @@ channel
3606
3753
  .on('broadcast', { event: 'code-perm' }, ({ payload }) => {
3607
3754
  const s = payload?.term && sessions.get(payload.term)
3608
3755
  const p = s && payload.id && s.pending.get(payload.id)
3609
- // Pass the raw decision through — normal tools use allow/deny, plan cards
3610
- // use run/accept/keep (claude-session interprets). Default deny on missing.
3611
- // Answered from a device → retract the "needs you" banner on every OTHER device
3612
- // (permNotifier.resolve is a no-op unless its grace timer already fired).
3613
- if (p) {
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
+ }
3614
3773
  if (p.timer) clearTimeout(p.timer)
3615
3774
  s.pending.delete(payload.id)
3616
3775
  s.permNotifier?.resolve(payload.id)
3617
- p.resolve(pendingResolution(p, payload))
3776
+ p.resolve(pendingResolution(p, { ...payload, decision: authority.decision }))
3618
3777
  announce()
3619
- }
3778
+ })
3620
3779
  })
3621
3780
  .on('broadcast', { event: 'code-abort' }, ({ payload }) => {
3622
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)
@@ -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
- return { busy: !!busy, lastActionAt, lastActionAgeMs, statusFlag, statusReason }
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.busy ? 'busy' : 'idle'} · ${formatLaneAge(x.lastActionAgeMs)}${x.statusFlag ? ` · ${x.statusFlag}` : ''}`
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()
@@ -220,7 +232,7 @@ export const formatPeek = ({ selfId, sessions, terms, names = {}, terminal, line
220
232
  } else {
221
233
  body = cleanPtyTail(hit.buf, n).join('\n') || '(no output yet)'
222
234
  }
223
- return `Terminal ${label(hit)} (${hit.kind} · ${hit.cmd}) — last ${n} lines:\n${body}`
235
+ return `Terminal ${label(hit)} (${hit.kind} · ${hit.cmd}) · ${laneStatusText(hit)} — last ${n} lines:\n${body}`
224
236
  }
225
237
 
226
238
  // A native Claude Code account failure is provider-wide, not model-specific. Retrying
@@ -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"),
@@ -417,8 +556,18 @@ export const spawnDecision = ({ hop = 0, spawnTimes = [], now = 0, spawnedLive =
417
556
  // Only lanes this caller spawned are suggested: close_terminal refuses the others, and
418
557
  // suggesting a sibling's lane is how you get an agent killing someone else's work.
419
558
  export const CLOSABLE_HINT_MAX = 6
559
+ export const closeLaneDecision = ({ callerId, spawnedBy, busy } = {}) => {
560
+ if (!callerId || spawnedBy !== callerId) return { ok: false, reason: 'not-owner' }
561
+ if (busy) return { ok: false, reason: 'busy' }
562
+ return { ok: true }
563
+ }
564
+
420
565
  export const closableLanes = (callerId, lanes = []) =>
421
- (Array.isArray(lanes) ? lanes : []).filter((l) => l && l.spawnedBy === callerId && !l.busy)
566
+ (Array.isArray(lanes) ? lanes : []).filter((l) => l && closeLaneDecision({
567
+ callerId,
568
+ spawnedBy: l.spawnedBy,
569
+ busy: l.busy,
570
+ }).ok)
422
571
 
423
572
  export const formatClosableHint = (callerId, lanes = [], max = CLOSABLE_HINT_MAX) => {
424
573
  const idle = closableLanes(callerId, lanes)
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', 'model-switch', 'queue-add', 'queue-cancel', 'propose-done', 'update-available'])
65
- export const seqable = (evt) => !!evt && typeof evt === 'object' && !NO_SEQ_KINDS.has(evt.kind)
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.242",
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
+ }