thinkpool-pair 0.7.284 → 0.7.286

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/account.mjs CHANGED
@@ -22,6 +22,7 @@ import { resolveServeDir } from './serve-dir.mjs'
22
22
  import { pairKeyFor, pairTopic, CROSSROOM_BUS } from './cross-terminal.mjs'
23
23
  import { installedAgentCommands } from './agent-detect.mjs'
24
24
  import { hostMemoryAdmission } from './host-memory.mjs'
25
+ import { createPairBusBroker, mergePairRoomRoster, pairBusStatusFromRealtime, PAIR_BUS_STATUS } from './pair-bus.mjs'
25
26
 
26
27
  const VERSION = (() => { try { return JSON.parse(fs.readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version } catch { return null } })()
27
28
 
@@ -431,8 +432,7 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
431
432
  // relays peek/post requests between a local room child and the partner's supervisor.
432
433
  // Spec: docs/specs/2026-06-30-cross-room-pair-bus.md
433
434
  const roomPartner = new Map() // room -> the OTHER participant's uid (the pair partner), for 2-person rooms
434
- const pairChannels = new Map() // partnerUid -> { channel, key } (one bus per distinct partner)
435
- const busWaiters = new Map() // nonce -> { mode:'first'|'collect', resolve, timer, acc? } for OUR outbound bus requests
435
+ const pairChannels = new Map() // partnerUid -> { channel, key, status } (one bus per distinct partner)
436
436
  const postBucket = new Map() // partnerUid -> { count, resetAt } inbound cross-room-post rate limit (runaway-cost backstop)
437
437
  const POST_BUCKET = { max: 6, windowMs: 60_000 } // ≤6 inbound cross-room posts / partner / minute
438
438
  let applyRequested = false // a user clicked "apply" in some room (Slice 3)
@@ -706,34 +706,17 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
706
706
  }
707
707
 
708
708
  // ── pair-bus plumbing (function declarations hoist, so order here is free) ──
709
- // Resolve one of OUR outbound bus requests. 'first' resolves on the first reply
710
- // (peek/post — a single partner answers); 'collect' accumulates every reply within
711
- // the window (list — several partners may each answer) and resolves when it closes.
712
- const resolveBus = (payload) => {
713
- if (!payload?.nonce) return
714
- const w = busWaiters.get(payload.nonce)
715
- if (!w) return
716
- if (w.mode === 'collect') { w.acc.push(payload); return }
717
- clearTimeout(w.timer); busWaiters.delete(payload.nonce); w.resolve(payload)
718
- }
719
- const busSendAll = (event, payload) => {
720
- let sent = 0
721
- for (const { channel } of pairChannels.values()) { try { channel.send({ type: 'broadcast', event, payload }); sent++ } catch { /* channel down */ } }
722
- return sent
709
+ const pairBus = createPairBusBroker({ channels: pairChannels, randomId: randomUUID })
710
+ const resolveBus = (payload) => pairBus.resolve(payload)
711
+ const busRequestFirst = (event, payload, timeoutMs) => pairBus.requestFirst(event, payload, timeoutMs)
712
+ const busRequestCollect = (event, payload, windowMs) => pairBus.requestCollect(event, payload, windowMs)
713
+ const authorizedPairRooms = async (sourceRoom) => {
714
+ try {
715
+ const listed = await withTimeout(sb.rpc('list_pair_rooms', { p_code: sourceRoom }), 2500, 'paired-room roster')
716
+ if (listed?.error || !Array.isArray(listed?.data)) return { verified: false, rooms: [] }
717
+ return { verified: true, rooms: listed.data }
718
+ } catch { return { verified: false, rooms: [] } }
723
719
  }
724
- const busRequestFirst = (event, payload, timeoutMs) => new Promise((resolve) => {
725
- const nonce = randomUUID()
726
- if (!busSendAll(event, { ...payload, nonce })) return resolve(null)
727
- const timer = setTimeout(() => { busWaiters.delete(nonce); resolve(null) }, timeoutMs); timer.unref?.()
728
- busWaiters.set(nonce, { mode: 'first', resolve, timer })
729
- })
730
- const busRequestCollect = (event, payload, windowMs) => new Promise((resolve) => {
731
- const nonce = randomUUID()
732
- if (!busSendAll(event, { ...payload, nonce })) return resolve([])
733
- const acc = []
734
- const timer = setTimeout(() => { busWaiters.delete(nonce); resolve(acc) }, windowMs); timer.unref?.()
735
- busWaiters.set(nonce, { mode: 'collect', acc, resolve, timer })
736
- })
737
720
  // Inbound cross-room-post rate limit, per partner (a partner's agent posting into
738
721
  // MY rooms spends MY tokens — the runaway-cost backstop, on top of the per-turn cap).
739
722
  const allowInboundPost = (partnerUid) => {
@@ -787,15 +770,19 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
787
770
  if (!partnerUid || pairChannels.has(partnerUid)) return
788
771
  const topic = pairTopic(key); if (!topic) return
789
772
  const ch = sb.channel(topic, { config: { private: true, broadcast: { self: false } } })
773
+ const entry = { channel: ch, key, status: PAIR_BUS_STATUS.JOINING }
790
774
  ch.on('broadcast', { event: 'peer-list-req' }, ({ payload }) => onPeerListReq(partnerUid, ch, payload))
791
775
  .on('broadcast', { event: 'peer-list-res' }, ({ payload }) => resolveBus(payload))
792
776
  .on('broadcast', { event: 'peer-peek-req' }, ({ payload }) => onPeerPeekReq(partnerUid, ch, payload))
793
777
  .on('broadcast', { event: 'peer-peek-res' }, ({ payload }) => resolveBus(payload))
794
778
  .on('broadcast', { event: 'peer-post-req' }, ({ payload }) => onPeerPostReq(partnerUid, ch, payload))
795
779
  .on('broadcast', { event: 'peer-post-res' }, ({ payload }) => resolveBus(payload))
796
- .subscribe()
797
- pairChannels.set(partnerUid, { channel: ch, key })
798
- process.stderr.write(`\n ◆ pair bus open — cross-machine Ensemble reach with your partner.\n`)
780
+ .subscribe((status) => {
781
+ entry.status = pairBusStatusFromRealtime(status)
782
+ if (status === 'SUBSCRIBED') process.stderr.write(`\n ◆ pair bus subscribed — cross-machine Ensemble reach with your partner.\n`)
783
+ else if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT') process.stderr.write(`\n ◇ pair bus ${status.toLowerCase().replace('_', ' ')} — partner sessions are offline until it reconnects.\n`)
784
+ })
785
+ pairChannels.set(partnerUid, entry)
799
786
  }
800
787
  // Forward a local child's cross-room request to the bus (Tier 2 peek / Tier 3 post)
801
788
  // when the target room is NOT served on this machine. Used inside the child handler.
@@ -923,11 +910,15 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
923
910
  ;(async () => {
924
911
  if (process.env.TP_CROSSROOM_OFF === '1') { try { child.send({ t: 'pair-list-res', reqId: m.reqId, rooms: [], error: 'Cross-session reach is turned off on this bridge.' }) } catch { /* child gone */ } return }
925
912
  const local = [...children.keys()].filter((r) => r !== room).map((r) => ({ code: r, name: roomNames.get(r) || null }))
926
- let remote = []
927
- if (process.env.TP_PAIRBUS_OFF !== '1' && pairChannels.size) {
928
- const replies = await busRequestCollect('peer-list-req', {}, 1200)
929
- remote = replies.flatMap((p) => (Array.isArray(p?.rooms) ? p.rooms : []))
913
+ let authorized = []; let replies = []
914
+ if (process.env.TP_PAIRBUS_OFF !== '1') {
915
+ const result = await Promise.all([
916
+ authorizedPairRooms(room),
917
+ pairChannels.size ? busRequestCollect('peer-list-req', {}, 1200) : Promise.resolve([]),
918
+ ])
919
+ authorized = result[0].rooms; replies = result[1]
930
920
  }
921
+ const remote = mergePairRoomRoster({ authorized, replies, myUid: session.user.id, localCodes: children.keys() })
931
922
  try { child.send({ t: 'pair-list-res', reqId: m.reqId, rooms: [...local, ...remote] }) } catch { /* child gone */ }
932
923
  })()
933
924
  }
@@ -946,7 +937,14 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
946
937
  return
947
938
  }
948
939
  // REMOTE target — ask the pair bus(es).
949
- if (process.env.TP_PAIRBUS_OFF === '1' || !pairChannels.size) return reply({ error: `Session ${target} is not running on this machine, and no pair bus is open to reach it.` })
940
+ if (process.env.TP_PAIRBUS_OFF === '1') return reply({ error: 'Cross-session reach is turned off on this bridge.' })
941
+ const [authorized, live] = await Promise.all([
942
+ authorizedPairRooms(room),
943
+ pairChannels.size ? busRequestCollect('peer-list-req', {}, 800) : Promise.resolve([]),
944
+ ])
945
+ if (authorized.verified && !authorized.rooms.some((r) => r?.code === target && r?.owner_id !== session.user.id)) return reply({ error: `Room ${target} is not shared with this pair.` })
946
+ if (!pairChannels.size) return reply({ error: authorized.verified ? `Room ${target} is paired with you, but its host bridge is offline.` : `Session ${target} is not running on this machine, and no pair bus is open to reach it.` })
947
+ if (!live.some((p) => p?.rooms?.some((r) => r?.code === target))) return reply({ error: `Room ${target} is paired with you, but its host bridge is offline.` })
950
948
  const res = await busPeek(target, m.terminal, m.lines)
951
949
  if (!res) return reply({ error: `Room ${target} did not respond — it may be offline, or not shared with you.` })
952
950
  reply({ text: res.text, error: res.error })
@@ -966,8 +964,16 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
966
964
  catch { pairPending.delete(reqId); reply({ error: `Could not reach session ${target}.` }) }
967
965
  return
968
966
  }
969
- // REMOTE target — over the pair bus.
970
- if (!pairChannels.size) return reply({ error: `Room ${target} is not on this machine, and no pair bus is open to reach it.` })
967
+ // REMOTE target — prove the partner's bridge is currently answering
968
+ // before starting a 180s human-approval wait. The DB-backed roster may
969
+ // legitimately list an offline paired room; reachability comes from the bus.
970
+ const [authorized, live] = await Promise.all([
971
+ authorizedPairRooms(room),
972
+ pairChannels.size ? busRequestCollect('peer-list-req', {}, 1200) : Promise.resolve([]),
973
+ ])
974
+ if (authorized.verified && !authorized.rooms.some((r) => r?.code === target && r?.owner_id !== session.user.id)) return reply({ error: `Room ${target} is not shared with this pair.` })
975
+ if (!pairChannels.size) return reply({ error: authorized.verified ? `Room ${target} is paired with you, but its host bridge is offline.` : `Room ${target} is not on this machine, and no pair bus is open to reach it.` })
976
+ if (!live.some((p) => p?.rooms?.some((r) => r?.code === target))) return reply({ error: `Room ${target} is paired with you, but its host bridge is offline — no recipient approval card can be raised yet.` })
971
977
  const res = await busPost(target, m.terminal, m.text, room, m.fromTerminalName)
972
978
  if (!res) return reply({ error: `Room ${target} did not respond to the hand-off in time.` })
973
979
  reply({ ok: res.ok, ref: res.ref, error: res.error })
@@ -1101,6 +1107,7 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
1101
1107
  keepFresh.cancel()
1102
1108
  releaseSingleton()
1103
1109
  for (const c of children.values()) { try { c.kill(sig || 'SIGTERM') } catch { /* noop */ } }
1110
+ pairBus.close()
1104
1111
  for (const { channel } of pairChannels.values()) { try { sb.removeChannel(channel) } catch { /* noop */ } } // tear down pair buses (Tier 2/3)
1105
1112
  // Flush the presence LEAVE before exiting so the dashboard flips to
1106
1113
  // "not connected" in realtime (don't fire-and-forget the untrack).
package/bridge.mjs CHANGED
@@ -113,7 +113,7 @@ const flowRedispatch = new Map()
113
113
  // wave BEFORE overrun. Lives bridge-side because waves dispatch across separate
114
114
  // broadcasts; without persistent state the cap can never bite.
115
115
  const flowBudgets = new Map()
116
- import { formatPeek, PEEK, siblingsOf, resolveSibling, crossPostDecision, CROSSPOST, spawnDecision, SPAWN, CROSSROOM, formatPairRoster, crossRoomPostDecision, formatRoomNow, formatClosableHint, closeLaneDecision, laneStatusOf, nativeClaudeFallbackHint, buildDispatchPreview, authorizeDirectDispatch } from './cross-terminal.mjs'
116
+ import { formatPeek, PEEK, siblingsOf, resolveSibling, crossPostDecision, CROSSPOST, spawnDecision, SPAWN, CROSSROOM, CROSSROOM_BUS, formatPairRoster, crossRoomPostDecision, formatRoomNow, formatClosableHint, closeLaneDecision, laneStatusOf, nativeClaudeFallbackHint, buildDispatchPreview, authorizeDirectDispatch } from './cross-terminal.mjs'
117
117
  import { dispatchPendingRecap, recoverInterruptedTurn, recoverMissingResumeOnce, sendInterruptedContinue, shouldAttemptNativeResume, supersedeInterruptedResume } from './interrupted-resume.mjs'
118
118
  import { supersedeDispatchLease } from './dispatch-lease.mjs'
119
119
  import { turnInFlight } from './update-gate.mjs'
@@ -929,6 +929,37 @@ const terms = new Map()
929
929
  // PTY `terms` map so none of the byte-relay code paths (flush, resize,
930
930
  // pty-in, scrollback) ever touch them. id → { session, cmd, log, pending }.
931
931
  const sessions = new Map()
932
+
933
+ // One monotonically increasing authority per bridge process. Realtime preserves
934
+ // messages best-effort, not total order across a reconnect; clients use this to
935
+ // reject a late snapshot from an older channel while bridge_id distinguishes a
936
+ // real process restart (where the counter intentionally begins again).
937
+ let announceRev = 0
938
+
939
+ function syncStructuredTurn(entry, now = Date.now()) {
940
+ const busy = entry?.session?.turnActive ?? false
941
+ const changed = busy !== entry?._busyAnn
942
+ if (!changed) return false
943
+ if (busy) {
944
+ entry._turnRev = (Number(entry._turnRev) || 0) + 1
945
+ entry._turnStart = now
946
+ }
947
+ entry._busyAnn = busy
948
+ return true
949
+ }
950
+
951
+ function beginStructuredTurn(entry, now = Date.now()) {
952
+ if (entry?._busyAnn === true) return false
953
+ entry._turnRev = (Number(entry._turnRev) || 0) + 1
954
+ entry._turnStart = now
955
+ entry._busyAnn = true
956
+ return true
957
+ }
958
+
959
+ function stampStructuredTurn(entry, event) {
960
+ if (event && event.turnRev == null && Number(entry?._turnRev) > 0) event.turnRev = entry._turnRev
961
+ return event
962
+ }
932
963
  let attachedId = null
933
964
  let shuttingDown = false
934
965
 
@@ -1058,8 +1089,9 @@ const announce = () => {
1058
1089
  // (name-only, NEVER key/baseUrl) so a cross-device viewer can badge a lane opened
1059
1090
  // on a custom provider without the owner's account-channel registry.
1060
1091
  const provNames = providerNameMap()
1092
+ const rev = ++announceRev
1061
1093
  return bcast('bridge', {
1062
- v: 2, name, bridge_id: BRIDGE_ID, repo: repoLabel, branch: readBranch(),
1094
+ v: 2, name, bridge_id: BRIDGE_ID, rev, repo: repoLabel, branch: readBranch(),
1063
1095
  // sdkWarn: the auto-pulled agent SDK failed its boot compatibility smoke test —
1064
1096
  // surfaced so the room can show a banner (turns may misbehave; pin a good SDK).
1065
1097
  ...(sdkStatus.ok === false ? { sdkWarn: `${sdkStatus.version}: ${sdkStatus.reason}` } : {}),
@@ -1130,7 +1162,7 @@ const announce = () => {
1130
1162
  // laneStatusOf: authoritative busy/idle + last-action timestamp/age +
1131
1163
  // STUCK/BLOCKED alert. The bridge owns the turn and permission state, so
1132
1164
  // every roster consumer reads one status instead of reconstructing it.
1133
- ...[...sessions.entries()].map(([id, s]) => ({ id, cmd: s.cmd, kind: 'structured', runtime: s.runtime || 'claude', alive: true, ...laneStatusOf(s), ...(s.session?.turnActive && s._turnStart ? { turnStartedAt: s._turnStart } : {}), hasTranscript: s.log.length > 0, commands: s.commands, mode: s.mode || undefined, effort: s.effort || undefined, name: termNames[id] || undefined, model: s.model || undefined, capabilities: { ...structuredRuntimeMetadata(s.runtime || 'claude'), modes: structuredModesForLane(s.runtime || 'claude', s) }, ...(s.runtime === 'codex' ? { approvalPolicy: codexConfigForMode(s.mode).approvalPolicy, models: s.models || [], canSteer: s.session?.canSteer ?? false } : s.runtime === 'hermes' ? { models: s.models || [], canSteer: s.session?.canSteer ?? false } : {}), ...(s.archiveOldestSeq != null ? { oldestSeq: s.archiveOldestSeq } : {}), ...(s.spawnedBy ? { spawned: true, spawnedBy: s.spawnedBy } : {}), ...(s.sideParent ? { sideParent: s.sideParent, sideTask: s.sideTask || undefined, sideHandback: !!s.sideHandback } : {}), ...(s.flowSessionId ? { flowId: s.flowSessionId, flowRole: s.flowTaskKey ? 'lane' : 'conductor' } : {}),
1165
+ ...[...sessions.entries()].map(([id, s]) => ({ id, cmd: s.cmd, kind: 'structured', runtime: s.runtime || 'claude', alive: true, ...laneStatusOf(s), turnRev: Number(s._turnRev) || 0, ...(s.session?.turnActive && s._turnStart ? { turnStartedAt: s._turnStart } : {}), hasTranscript: s.log.length > 0, commands: s.commands, mode: s.mode || undefined, effort: s.effort || undefined, name: termNames[id] || undefined, model: s.model || undefined, capabilities: { ...structuredRuntimeMetadata(s.runtime || 'claude'), modes: structuredModesForLane(s.runtime || 'claude', s) }, ...(s.runtime === 'codex' ? { approvalPolicy: codexConfigForMode(s.mode).approvalPolicy, models: s.models || [], canSteer: s.session?.canSteer ?? false } : s.runtime === 'hermes' ? { models: s.models || [], canSteer: s.session?.canSteer ?? false } : {}), ...(s.archiveOldestSeq != null ? { oldestSeq: s.archiveOldestSeq } : {}), ...(s.spawnedBy ? { spawned: true, spawnedBy: s.spawnedBy } : {}), ...(s.sideParent ? { sideParent: s.sideParent, sideTask: s.sideTask || undefined, sideHandback: !!s.sideHandback } : {}), ...(s.flowSessionId ? { flowId: s.flowSessionId, flowRole: s.flowTaskKey ? 'lane' : 'conductor' } : {}),
1134
1166
  // provider: the registered LLM-provider this lane runs on, NAME-ONLY {id,name}
1135
1167
  // (NEVER the key or baseUrl). Additive; older clients ignore it. Omitted for the
1136
1168
  // built-in/default Claude path (no badge). Makes the lane's provider badge +
@@ -1684,6 +1716,7 @@ function pushLog(entry, evt) {
1684
1716
  // every old user message stacks at the TOP of the timeline (room XE3IQ, 2026-06-25).
1685
1717
  // A real ts makes ordering wall-clock — immune to eviction AND /clear seq resets.
1686
1718
  // stampEvent is idempotent, so the callers that already stamp are unaffected.
1719
+ stampStructuredTurn(entry, evt)
1687
1720
  stampEvent(evt)
1688
1721
  entry.lastActionAt = evt.ts
1689
1722
  entry.lastEvent = evt
@@ -1818,6 +1851,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
1818
1851
  // order (not readdir/filesystem order). Legacy recs (no openedAt) derive it from the
1819
1852
  // first transcript event ts, so even the first post-fix restart is ordered right.
1820
1853
  openedAt: openedAt || (Array.isArray(log) ? (log.find((e) => e?.ts)?.ts || 0) : 0) || Date.now() }
1854
+ entry._turnRev = entry.log.reduce((max, event) => Math.max(max, Number(event?.turnRev) || 0), 0)
1821
1855
  entry.interruptedRecap = restoredTurnOpen(entry.log) ? buildRecapFromLog(entry.log, RECAP_CAP) : null
1822
1856
  // Slice 3 — a permission card left unanswered past the grace window pushes
1823
1857
  // "<lane> — needs you: <what>"; answering it anywhere retracts the banner
@@ -2272,7 +2306,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2272
2306
  if (target === room) return okText('That is this room — use read_terminal for terminals in your own room.')
2273
2307
  entry.pairPeekCount = (entry.pairPeekCount || 0) + 1
2274
2308
  if (entry.pairPeekCount > CROSSROOM.peekPerTurnCap) return okText(`Cross-session read limit reached for this turn (${CROSSROOM.peekPerTurnCap}). Continue with what you have.`)
2275
- const res = await pairRequest('pair-peek-req', { targetRoom: target, terminal: args?.terminal, lines: args?.lines })
2309
+ const res = await pairRequest('pair-peek-req', { targetRoom: target, terminal: args?.terminal, lines: args?.lines }, CROSSROOM_BUS.busTimeoutMs + 2_000)
2276
2310
  if (res?.error) return okText(res.error)
2277
2311
  return okText(res?.text || `Room ${target} returned nothing.`)
2278
2312
  },
@@ -2841,6 +2875,8 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2841
2875
  // replay-union dedupes ONLY by cid, and SDK events carry none — without an
2842
2876
  // id, an event that arrives both live AND in a reconnect replay renders
2843
2877
  // twice (the 2026-06-19 duplicate-message bug). See event-id.mjs.
2878
+ const busyChanged = syncStructuredTurn(entry)
2879
+ stampStructuredTurn(entry, evt)
2844
2880
  stampEvent(evt)
2845
2881
  const stalledChanged = evt.kind === 'stalled' ? !entry.stalled : !!entry.stalled
2846
2882
  entry.stalled = evt.kind === 'stalled'
@@ -2856,10 +2892,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2856
2892
  // The RISING edge is also where a turn's wall clock starts — the only place the
2857
2893
  // bridge sees "this lane just began working". `result`/`error` reads it to decide
2858
2894
  // whether the turn was long enough that nobody was watching (Slice 3).
2859
- { const busyNow = entry.session?.turnActive ?? false
2860
- const busyChanged = busyNow !== entry._busyAnn
2861
- if (busyChanged) { if (busyNow) entry._turnStart = Date.now(); entry._busyAnn = busyNow }
2862
- if (busyChanged || stalledChanged) announce() }
2895
+ if (busyChanged || stalledChanged) announce()
2863
2896
  // App Server steering is opt-in and can fall back to exec at runtime. Keep
2864
2897
  // the roster capability authoritative so the browser never immediate-sends
2865
2898
  // into an exec-only turn and loses its shared queue.
@@ -3909,7 +3942,12 @@ channel
3909
3942
  const nativeImages = s.runtime === 'codex' || s.runtime === 'hermes'
3910
3943
  ? await waitForNativeImages(payload.files, { updir: UPDIR })
3911
3944
  : []
3945
+ // Sample the runtime before dispatch so a missed prior falling edge cannot
3946
+ // make this genuinely new turn inherit the previous turn's revision.
3947
+ syncStructuredTurn(s)
3912
3948
  const accepted = s.session.sendTurn(sendText, nativeImages.length ? { images: nativeImages } : undefined)
3949
+ if (accepted === false) syncStructuredTurn(s)
3950
+ else beginStructuredTurn(s)
3913
3951
  echoYou()
3914
3952
  if (accepted === false) {
3915
3953
  // A runtime that did not accept a turn must still close the optimistic
package/codex-session.mjs CHANGED
@@ -33,6 +33,7 @@ import { startCodexMcpHttp } from './codex-mcp-http.mjs'
33
33
  import { CODEX_THINKPOOL_FIRST_TURN_PREAMBLE, buildThinkPoolTurnGuidance, createRoomContextSelector, usesFullThinkPoolReminder } from './thinkpool-room-prompt.mjs'
34
34
  import { questionAnswerResponse } from './question-response.mjs'
35
35
  import { codexReviewTarget, isCodexCompactCommand } from './codex-commands.mjs'
36
+ import { createCumulativeEventRelay } from './cumulative-event-relay.mjs'
36
37
 
37
38
  const DEFAULT_SANDBOX = 'workspace-write'
38
39
  const SAFE_SANDBOXES = new Set(['read-only', 'workspace-write', 'danger-full-access'])
@@ -402,8 +403,9 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
402
403
  let chain = Promise.resolve()
403
404
  const queue = []
404
405
 
406
+ const relayEvent = createCumulativeEventRelay((event) => { try { onEvent?.(event) } catch { /* consumer isolation */ } })
405
407
  const mapper = new CodexEventMapper({
406
- onEvent,
408
+ onEvent: relayEvent,
407
409
  model: model || null,
408
410
  usageSnapshotForSession: (id) => {
409
411
  const snapshot = readCodexThreadUsage(id)
@@ -425,10 +427,10 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
425
427
  }
426
428
  const emitTurnBoundary = (event) => {
427
429
  turnActive = false
428
- try { onEvent?.(event) } catch { /* noop */ }
430
+ relayEvent(event)
429
431
  }
430
432
 
431
- const note = (text) => { try { onEvent?.({ kind: 'note', text }) } catch { /* noop */ } }
433
+ const note = (text) => relayEvent({ kind: 'note', text })
432
434
 
433
435
  const closeAppServerTurn = (turnId = activeTurnId) => {
434
436
  const id = String(turnId || '')
@@ -472,7 +474,7 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
472
474
  activeForm: step || '',
473
475
  status: status === 'inProgress' ? 'in_progress' : status === 'completed' ? 'completed' : 'pending',
474
476
  })).filter((todo) => todo.content)
475
- try { onEvent?.({ kind: 'assistant', blocks: [{ type: 'tool_use', id: `codex-plan:${params.turnId || 'active'}`, name: 'TodoWrite', input: { todos } }], parentToolUseId: null }) } catch { /* noop */ }
477
+ relayEvent({ kind: 'assistant', blocks: [{ type: 'tool_use', id: `codex-plan:${params.turnId || 'active'}`, name: 'TodoWrite', input: { todos } }], parentToolUseId: null })
476
478
  return
477
479
  }
478
480
  if (method === 'item/agentMessage/delta') {
@@ -484,7 +486,7 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
484
486
  // Cumulative transient snapshot. The bridge broadcasts this chrome event
485
487
  // without assigning transcript seq/archive rows; room.jsx replaces the
486
488
  // stable cid in place. item/completed emits one durable assistant row.
487
- try { onEvent?.({ kind: 'assistant_stream', cid: state.cid, text: state.text, itemId, streaming: true }) } catch { /* noop */ }
489
+ relayEvent({ kind: 'assistant_stream', cid: state.cid, text: state.text, itemId, streaming: true })
488
490
  return
489
491
  }
490
492
  if (method === 'item/started' || method === 'item/completed') {
@@ -496,7 +498,7 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
496
498
  if (raw.type === 'agentMessage' && streamedAgentItems.has(raw.id)) {
497
499
  const state = streamedAgentItems.get(raw.id)
498
500
  streamedAgentItems.delete(raw.id)
499
- try { onEvent?.({ kind: 'assistant', blocks: [{ type: 'text', text: raw.text || state.text }], parentToolUseId: null, replacesCid: state.cid }) } catch { /* noop */ }
501
+ relayEvent({ kind: 'assistant', blocks: [{ type: 'text', text: raw.text || state.text }], parentToolUseId: null, replacesCid: state.cid })
500
502
  return
501
503
  }
502
504
  }
@@ -639,7 +641,7 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
639
641
  // than leaving a replay-less streaming shell behind.
640
642
  for (const state of streamedAgentItems.values()) {
641
643
  if (!state.text) continue
642
- try { onEvent?.({ kind: 'assistant', blocks: [{ type: 'text', text: state.text }], parentToolUseId: null, replacesCid: state.cid }) } catch { /* noop */ }
644
+ relayEvent({ kind: 'assistant', blocks: [{ type: 'text', text: state.text }], parentToolUseId: null, replacesCid: state.cid })
643
645
  }
644
646
  activeTurnId = null
645
647
  turnActive = false
@@ -683,7 +685,7 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
683
685
  } finally {
684
686
  for (const state of streamedAgentItems.values()) {
685
687
  if (!state.text) continue
686
- try { onEvent?.({ kind: 'assistant', blocks: [{ type: 'text', text: state.text }], parentToolUseId: null, replacesCid: state.cid }) } catch { /* noop */ }
688
+ relayEvent({ kind: 'assistant', blocks: [{ type: 'text', text: state.text }], parentToolUseId: null, replacesCid: state.cid })
687
689
  }
688
690
  activeTurnId = null
689
691
  turnActive = false
@@ -817,7 +819,7 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
817
819
  if (!turnActive && queue.length === 0 && !appServerThreadReady && !child) {
818
820
  const gate = typeof admitStart === 'function' ? admitStart() : { ok: true }
819
821
  if (gate?.ok === false) {
820
- try { onEvent?.({ kind: 'error', message: gate.reason || 'Host memory is critically low. This Codex runtime was not started.', recoverable: true }) } catch { /* noop */ }
822
+ relayEvent({ kind: 'error', message: gate.reason || 'Host memory is critically low. This Codex runtime was not started.', recoverable: true })
821
823
  return false
822
824
  }
823
825
  }
@@ -848,7 +850,7 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
848
850
  queue.push({ text, options, promptIndex, forceFullReminder: thisTurnForceFull })
849
851
  if (!turnActive && queue.length === 1) pump()
850
852
  } else {
851
- try { onEvent?.({ kind: 'error', message: 'Codex steering delivery is uncertain; the message was not replayed automatically.', recoverable: true }) } catch { /* noop */ }
853
+ relayEvent({ kind: 'error', message: 'Codex steering delivery is uncertain; the message was not replayed automatically.', recoverable: true })
852
854
  }
853
855
  })
854
856
  return true
@@ -884,6 +886,7 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
884
886
  end() {
885
887
  ended = true
886
888
  turnActive = false
889
+ relayEvent.cancel()
887
890
  queue.length = 0
888
891
  closeAppServerTurn(activeTurnId)
889
892
  if (child) { try { child.kill() } catch { /* noop */ } }
@@ -912,7 +915,7 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
912
915
  setEffort(nextEffort) {
913
916
  if (turnActive || !EFFORT_LEVELS.has(nextEffort)) return false
914
917
  activeEffort = nextEffort
915
- try { onEvent?.({ kind: 'effort', level: activeEffort }) } catch { /* noop */ }
918
+ relayEvent({ kind: 'effort', level: activeEffort })
916
919
  return true
917
920
  },
918
921
  clearContext() {
@@ -644,7 +644,7 @@ export const formatPairRoster = (rooms, { thisRoom, error } = {}) => {
644
644
  // `host` is set for a Tier-2 cross-MACHINE room (it lives on a partner's Mac,
645
645
  // reached over the pair bus); a same-machine Tier-1 room has none. Rendering the
646
646
  // host tells the agent (and the people) which rooms are "over there".
647
- const rows = list.map((r) => `- ${r.code}${r.name ? ` · "${r.name}"` : ''}${r.host ? ` · on ${r.host}` : ''}`)
647
+ const rows = list.map((r) => `- ${r.code}${r.name ? ` · "${r.name}"` : ''}${r.host ? ` · on ${r.host}` : ''}${r.online === false ? ' · partner bridge offline' : ''}`)
648
648
  return `Your other ThinkPool Code sessions (call read_session with a room code to look inside one):\n${rows.join('\n')}`
649
649
  }
650
650
 
@@ -0,0 +1,48 @@
1
+ // Cumulative provider snapshots are replace-in-place UI state, not transcript
2
+ // facts. Deliver the first immediately and coalesce a burst to its latest frame;
3
+ // any durable event flushes the pending snapshot first to preserve wire order.
4
+ export function createCumulativeEventRelay(emit, waitMs = 150) {
5
+ let lastSentAt = 0
6
+ let pending = null
7
+ let timer = null
8
+
9
+ const send = (event) => {
10
+ lastSentAt = Date.now()
11
+ emit(event)
12
+ }
13
+ const flush = () => {
14
+ if (timer) clearTimeout(timer)
15
+ timer = null
16
+ if (!pending) return
17
+ const event = pending
18
+ pending = null
19
+ send(event)
20
+ }
21
+ const relay = (event) => {
22
+ if (event?.kind !== 'assistant_stream') {
23
+ flush()
24
+ emit(event)
25
+ return
26
+ }
27
+ const remaining = waitMs - (Date.now() - lastSentAt)
28
+ if (!lastSentAt || remaining <= 0) {
29
+ pending = null
30
+ if (timer) clearTimeout(timer)
31
+ timer = null
32
+ send(event)
33
+ return
34
+ }
35
+ pending = event
36
+ if (!timer) {
37
+ timer = setTimeout(flush, remaining)
38
+ timer.unref?.()
39
+ }
40
+ }
41
+ relay.flush = flush
42
+ relay.cancel = () => {
43
+ if (timer) clearTimeout(timer)
44
+ timer = null
45
+ pending = null
46
+ }
47
+ return relay
48
+ }
@@ -11,6 +11,7 @@ import { startCodexMcpHttp } from './codex-mcp-http.mjs'
11
11
  import { autoAllow, classifyRisk } from './claude-session.mjs'
12
12
  import { crossPostNeedsCard } from './cross-terminal.mjs'
13
13
  import { buildThinkPoolTurnGuidance, createRoomContextSelector, usesFullThinkPoolReminder } from './thinkpool-room-prompt.mjs'
14
+ import { createCumulativeEventRelay } from './cumulative-event-relay.mjs'
14
15
 
15
16
  export const HERMES_COMMAND = 'thinkpool'
16
17
  export const HERMES_ACP_PROTOCOL_VERSION = 1
@@ -112,7 +113,7 @@ export function startHermesSession({
112
113
  ? 'dont_ask'
113
114
  : 'default'
114
115
 
115
- const emit = (event) => { try { onEvent?.(event) } catch { /* consumer isolation */ } }
116
+ const emit = createCumulativeEventRelay((event) => { try { onEvent?.(event) } catch { /* consumer isolation */ } })
116
117
 
117
118
  const thinkpoolPeerTool = (name) => {
118
119
  const lower = String(name || '').toLowerCase()
@@ -598,6 +599,7 @@ export function startHermesSession({
598
599
  end() {
599
600
  ended = true
600
601
  turnActive = false
602
+ emit.cancel()
601
603
  queuedTurns.length = 0
602
604
  queueDrainPending = false
603
605
  if (sessionId && client?.alive) void client.request('session/close', { sessionId }, 1500).catch(() => {}).finally(() => client?.end())
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.284",
3
+ "version": "0.7.286",
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": {
@@ -27,6 +27,7 @@
27
27
  "codex-mcp-http.mjs",
28
28
  "lane-worktree.mjs",
29
29
  "codex-event-mapper.mjs",
30
+ "cumulative-event-relay.mjs",
30
31
  "codex-commands.mjs",
31
32
  "acp-client.mjs",
32
33
  "hermes-session.mjs",
@@ -54,6 +55,7 @@
54
55
  "session-store.mjs",
55
56
  "side-lane.mjs",
56
57
  "cross-terminal.mjs",
58
+ "pair-bus.mjs",
57
59
  "lane-lifecycle.mjs",
58
60
  "interrupted-resume.mjs",
59
61
  "dispatch-lease.mjs",
package/pair-bus.mjs ADDED
@@ -0,0 +1,98 @@
1
+ // Deterministic request broker for the cross-machine pair bus.
2
+ //
3
+ // Supabase channel.send() is asynchronous and a channel is not usable until its
4
+ // subscription callback reports SUBSCRIBED. Keeping those facts in one tested
5
+ // primitive prevents a joining/dead channel from impersonating a reachable peer
6
+ // and prevents a fast reply from arriving before its waiter exists.
7
+
8
+ export const PAIR_BUS_STATUS = Object.freeze({
9
+ JOINING: 'joining',
10
+ SUBSCRIBED: 'subscribed',
11
+ ERROR: 'error',
12
+ CLOSED: 'closed',
13
+ })
14
+
15
+ export const pairBusStatusFromRealtime = (status) => {
16
+ if (status === 'SUBSCRIBED') return PAIR_BUS_STATUS.SUBSCRIBED
17
+ if (status === 'CLOSED') return PAIR_BUS_STATUS.CLOSED
18
+ if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT') return PAIR_BUS_STATUS.ERROR
19
+ return PAIR_BUS_STATUS.JOINING
20
+ }
21
+
22
+ export const subscribedPairChannels = (channels) =>
23
+ [...(channels?.values?.() || [])].filter((entry) =>
24
+ entry?.status === PAIR_BUS_STATUS.SUBSCRIBED && entry?.channel?.send)
25
+
26
+ export async function sendPairBroadcast (channels, event, payload) {
27
+ const results = await Promise.all(subscribedPairChannels(channels).map(async ({ channel }) => {
28
+ try { return (await channel.send({ type: 'broadcast', event, payload })) === 'ok' }
29
+ catch { return false }
30
+ }))
31
+ return results.filter(Boolean).length
32
+ }
33
+
34
+ export function createPairBusBroker ({ channels, randomId, setTimer = setTimeout, clearTimer = clearTimeout } = {}) {
35
+ if (!channels?.values || typeof randomId !== 'function') throw new TypeError('channels and randomId are required')
36
+ const waiters = new Map()
37
+
38
+ const resolve = (payload) => {
39
+ if (!payload?.nonce) return false
40
+ const waiter = waiters.get(payload.nonce)
41
+ if (!waiter) return false
42
+ if (waiter.mode === 'collect') { waiter.acc.push(payload); return true }
43
+ clearTimer(waiter.timer); waiters.delete(payload.nonce); waiter.finish(payload)
44
+ return true
45
+ }
46
+
47
+ const requestFirst = (event, payload = {}, timeoutMs) => new Promise((finish) => {
48
+ const nonce = randomId()
49
+ const timer = setTimer(() => { waiters.delete(nonce); finish(null) }, timeoutMs)
50
+ timer?.unref?.()
51
+ waiters.set(nonce, { mode: 'first', finish, timer })
52
+ void sendPairBroadcast(channels, event, { ...payload, nonce }).then((sent) => {
53
+ if (sent || !waiters.has(nonce)) return
54
+ clearTimer(timer); waiters.delete(nonce); finish(null)
55
+ })
56
+ })
57
+
58
+ const requestCollect = (event, payload = {}, windowMs) => new Promise((finish) => {
59
+ const nonce = randomId()
60
+ const acc = []
61
+ const timer = setTimer(() => { waiters.delete(nonce); finish(acc) }, windowMs)
62
+ timer?.unref?.()
63
+ waiters.set(nonce, { mode: 'collect', acc, finish, timer })
64
+ void sendPairBroadcast(channels, event, { ...payload, nonce }).then((sent) => {
65
+ if (sent || !waiters.has(nonce)) return
66
+ clearTimer(timer); waiters.delete(nonce); finish(acc)
67
+ })
68
+ })
69
+
70
+ const close = () => {
71
+ for (const [nonce, waiter] of waiters) {
72
+ clearTimer(waiter.timer)
73
+ waiter.finish(waiter.mode === 'collect' ? waiter.acc : null)
74
+ waiters.delete(nonce)
75
+ }
76
+ }
77
+
78
+ return Object.freeze({ resolve, requestFirst, requestCollect, close, pendingCount: () => waiters.size })
79
+ }
80
+
81
+ // The database is the authorization/discovery source; bus replies are the live
82
+ // reachability source. Paired rooms remain visible while their host bridge is
83
+ // offline and become online only when that host actually answers on the bus.
84
+ export function mergePairRoomRoster ({ authorized = [], replies = [], myUid, localCodes = [] } = {}) {
85
+ const local = new Set(localCodes)
86
+ const rooms = new Map()
87
+ for (const row of authorized) {
88
+ if (!row?.code || row.owner_id === myUid || local.has(row.code)) continue
89
+ rooms.set(row.code, { code: row.code, name: row.name || null, online: false })
90
+ }
91
+ for (const reply of replies) {
92
+ for (const row of Array.isArray(reply?.rooms) ? reply.rooms : []) {
93
+ if (!row?.code || local.has(row.code)) continue
94
+ rooms.set(row.code, { code: row.code, name: row.name || rooms.get(row.code)?.name || null, host: row.host || null, online: true })
95
+ }
96
+ }
97
+ return [...rooms.values()]
98
+ }