thinkpool-pair 0.7.285 → 0.7.287

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,8 @@ 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
+ import { createStandalonePairResponder, standalonePairIdentity } from './direct-pair-room.mjs'
117
118
  import { dispatchPendingRecap, recoverInterruptedTurn, recoverMissingResumeOnce, sendInterruptedContinue, shouldAttemptNativeResume, supersedeInterruptedResume } from './interrupted-resume.mjs'
118
119
  import { supersedeDispatchLease } from './dispatch-lease.mjs'
119
120
  import { turnInFlight } from './update-gate.mjs'
@@ -1186,6 +1187,7 @@ const announce = () => {
1186
1187
  // Spec: docs/specs/2026-06-30-cross-room-ensemble.md
1187
1188
  const IS_ACCOUNT_CHILD = !!(process.send && process.env.THINKPOOL_PAIR_ACCOUNT_CHILD === '1')
1188
1189
  const pairWaiters = new Map() // reqId -> { resolve, timer } for our OUTBOUND requests
1190
+ let standalonePairChannel = null // standalone owner bridge's direct paired-session responder
1189
1191
  // Send a cross-room request to the supervisor and resolve with its reply ({ rooms }
1190
1192
  // or { text } / { error }). Fails soft (never throws into the tool handler): a
1191
1193
  // standalone bridge (no supervisor) or a disabled/timed-out request returns { error }.
@@ -1267,6 +1269,51 @@ async function receiveCrossRoomPost({ fromRoom, fromHost, fromTerminalName, text
1267
1269
  return { ok: true, ref: String(targetId).slice(0, 8) }
1268
1270
  }
1269
1271
 
1272
+ // A room can be legitimately live without the account supervisor: `npx
1273
+ // thinkpool-pair <ROOM>` and room-specific services run bridge.mjs directly. The
1274
+ // old Tier-2 route listened only in account.mjs, so those rooms appeared live in
1275
+ // the dashboard (`tpcode:<room>`) but falsely offline to list/read/post_session.
1276
+ // Join the SAME private, RLS-gated pair bus here when this process is standalone
1277
+ // and owns a real two-person room. The target bridge then answers for this room
1278
+ // directly and reuses receiveCrossRoomPost(), including the mandatory red receipt
1279
+ // card. Account children skip this path to avoid duplicate replies/cards.
1280
+ async function startStandalonePairResponder () {
1281
+ if (IS_ACCOUNT_CHILD || process.env.TP_PAIRBUS_OFF === '1' || process.env.TP_CROSSROOM_OFF === '1') return
1282
+ if (!codeAuthToken || !myServeUid) return
1283
+ let row = null
1284
+ try {
1285
+ const url = `${SUPABASE_URL}/rest/v1/code_sessions?code=eq.${encodeURIComponent(room)}&select=code,name,owner_id,participants`
1286
+ const res = await fetch(url, { headers: { apikey: SUPABASE_ANON, Authorization: `Bearer ${codeAuthToken}` } })
1287
+ if (!res.ok) return
1288
+ const rows = await res.json().catch(() => [])
1289
+ row = Array.isArray(rows) ? rows[0] : null
1290
+ } catch { return }
1291
+ const identity = standalonePairIdentity({ row, myUid: myServeUid })
1292
+ if (!identity?.topic) return
1293
+
1294
+ const responder = createStandalonePairResponder({
1295
+ room,
1296
+ roomName: row?.name || null,
1297
+ host: os.hostname().replace(/\.local$/, ''),
1298
+ formatPeek: (payload) => formatPeek({ selfId: null, sessions, terms, names: termNames, terminal: payload.terminal, lines: payload.lines }),
1299
+ receivePost: (payload) => receiveCrossRoomPost(payload),
1300
+ })
1301
+ const ch = supabase.channel(identity.topic, { config: { private: true, broadcast: { self: false } } })
1302
+ const reply = async (event, payload) => {
1303
+ if (!payload) return
1304
+ try { await ch.send({ type: 'broadcast', event, payload }) } catch { /* bus reconnect handles the next request */ }
1305
+ }
1306
+ ch.on('broadcast', { event: 'peer-list-req' }, ({ payload }) => { void reply('peer-list-res', responder.list(payload)) })
1307
+ .on('broadcast', { event: 'peer-peek-req' }, ({ payload }) => { const out = responder.peek(payload); if (out) void reply('peer-peek-res', out) })
1308
+ .on('broadcast', { event: 'peer-post-req' }, ({ payload }) => { void responder.post(payload).then((out) => { if (out) return reply('peer-post-res', out) }) })
1309
+ .subscribe((status) => {
1310
+ if (status === 'SUBSCRIBED') process.stderr.write(`\n ◆ standalone pair bus subscribed — this live room can receive approval-gated cross-session tasks.\n`)
1311
+ else if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT') process.stderr.write(`\n ◇ standalone pair bus ${status.toLowerCase().replace('_', ' ')} — cross-session reach will retry with realtime.\n`)
1312
+ })
1313
+ standalonePairChannel = ch
1314
+ }
1315
+ void startStandalonePairResponder()
1316
+
1270
1317
  if (IS_ACCOUNT_CHILD) {
1271
1318
  // A SECOND message listener (Node allows many) dedicated to pair traffic — leaves
1272
1319
  // the account-child idle/token listener (bottom of file) untouched.
@@ -2306,7 +2353,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2306
2353
  if (target === room) return okText('That is this room — use read_terminal for terminals in your own room.')
2307
2354
  entry.pairPeekCount = (entry.pairPeekCount || 0) + 1
2308
2355
  if (entry.pairPeekCount > CROSSROOM.peekPerTurnCap) return okText(`Cross-session read limit reached for this turn (${CROSSROOM.peekPerTurnCap}). Continue with what you have.`)
2309
- const res = await pairRequest('pair-peek-req', { targetRoom: target, terminal: args?.terminal, lines: args?.lines })
2356
+ const res = await pairRequest('pair-peek-req', { targetRoom: target, terminal: args?.terminal, lines: args?.lines }, CROSSROOM_BUS.busTimeoutMs + 2_000)
2310
2357
  if (res?.error) return okText(res.error)
2311
2358
  return okText(res?.text || `Room ${target} returned nothing.`)
2312
2359
  },
@@ -4833,6 +4880,7 @@ async function shutdown(code = 0, farewell = true) {
4833
4880
  // socket is alive; on a wedge it won't flush and the hard backstop above wins).
4834
4881
  try { await channel.untrack() } catch { /* noop */ }
4835
4882
  try { await supabase.removeChannel(channel) } catch { /* noop */ }
4883
+ try { if (standalonePairChannel) await supabase.removeChannel(standalonePairChannel) } catch { /* noop */ }
4836
4884
  // BRG-2 (audit 2026-07-02): shutdown removed only the room channel, leaking the flow
4837
4885
  // topic (no presence to untrack, but the subscription + socket join outlived the
4838
4886
  // process teardown). Close it too so shutdown is symmetric.
@@ -720,7 +720,7 @@ export function startClaudeSession({ cwd, model, effort: initialEffort = 'high',
720
720
  'CROSS-TERMINAL HAND-OFF: you also have post_to_terminal(terminal, text) to send a message or task to ANOTHER AGENT terminal in this room (not a plain shell). Use it sparingly and only when the people clearly want the lanes to coordinate — e.g. "tell the backend terminal the API is ready", or to hand a sibling agent a concrete task. Every post requires a person in the room to approve a card before it is delivered, and an agent that was itself reached via a cross-post cannot post onward — so do not rely on it for chit-chat or loops. Prefer read_terminal to understand a sibling before you ever post to it.',
721
721
  'TERMINAL CREATION CONTRACT: conductors and workers use different tools. When a person explicitly asks to open, launch, start, or spawn a separate Cascade/conductor terminal, use open_main_terminal(name?, task?, model?) — it creates an independent MAIN terminal with no Ensemble owner. Never use spawn_terminal for that request, and if open_main_terminal is unavailable say so instead of substituting. Use spawn_terminal(name?, task?, model?, sliceType?) only for bounded WORKER SUB-TERMINALS when your authoritative role permits delegation. Workers never receive the creation tools and never conduct. Do not dump work into busy siblings. Spawned workers are always autonomous in bypassPermissions and never ask the room for an approval card; the bridge still enforces room caps, hop limits, the kill-switch, and isolated linked worktrees. Collect each worker with read_terminal and close_terminal immediately after using its result. Main conductors are independent terminals, keep their requested permission mode, and are not owned/closed through Ensemble.',
722
722
  'CROSS-SESSION AWARENESS: the Ensemble reaches across your SESSIONS, not just the terminals in this room. list_sessions() lists your OTHER ThinkPool Code rooms — both your own rooms running on this machine AND your partner\'s rooms in the same pair, reachable over the per-pair bus (a room on the partner\'s machine shows its host). read_session(session, terminal?) reads recent activity inside one (omit `terminal` to list that room\'s terminals, or pass a ref/name to read that lane). Both are READ-ONLY — they never change another session, and they reach ONLY your own rooms and rooms you share with your partner, never a stranger\'s. Reach for them when work spans rooms — "what\'s the other project up to", "pick up where the other session left off", or to check a long-running task elsewhere before you act here.',
723
- 'CROSS-SESSION HAND-OFF: post_to_session(session, text, terminal?) sends a task or message to an agent in ANOTHER of your rooms — your own, or your partner\'s over the pair bus. Use it sparingly and only when the people clearly want the rooms to coordinate — e.g. hand the API room\'s agent a concrete follow-up once the frontend is ready. It is dual-consent: a person in YOUR room approves sending, and a person in the TARGET room approves receiving, before anything is delivered — so never rely on it for chit-chat or loops, and an agent that was itself reached via a cross-room post cannot post onward to a third room. It spends real model tokens in the other room (maybe on the other person\'s machine), so prefer read_session to understand a room before you ever post into it, and only post one concrete hand-off at a time. All of this works only under the ThinkPool account bridge; a standalone room sees just its own terminals.',
723
+ 'CROSS-SESSION HAND-OFF: post_to_session(session, text, terminal?) sends a task or message to an agent in ANOTHER of your rooms — your own, or your partner\'s over the pair bus. Use it sparingly and only when the people clearly want the rooms to coordinate — e.g. hand the API room\'s agent a concrete follow-up once the frontend is ready. It is dual-consent: a person in YOUR room approves sending, and a person in the TARGET room approves receiving, before anything is delivered — so never rely on it for chit-chat or loops, and an agent that was itself reached via a cross-room post cannot post onward to a third room. It spends real model tokens in the other room (maybe on the other person\'s machine), so prefer read_session to understand a room before you ever post into it, and only post one concrete hand-off at a time. Outbound list/read/post tools need the ThinkPool account bridge; a standalone owner room can still receive a paired hand-off directly and will always raise its own approval card before delivery.',
724
724
  'SUBAGENT POLICY: in this room, a main conductor delegates worker slices through visible spawn_terminal Ensemble lanes. A requested separate conductor is created with open_main_terminal, never Ensemble. Worker, leaf, Side, and managed Flow lanes do their assigned work directly. Do NOT reach for built-in Task/Agent subagents: an in-process subagent is invisible to the room, cannot be peered at or steered, and its work is lost to the Ensemble.',
725
725
  'RESEARCH LANE: you have a `research` tool that runs a REAL multi-source web search + adversarial verification and returns each claim marked HELD or REJECTED with citations. Reach for it when the people would genuinely benefit from looking something external up or settling a question of current fact — pricing, "is X still maintained / deprecated", "is that benchmark real", a debate over facts you are not sure of. Do NOT run it unprompted or for things you already know: first OFFER in plain language ("want me to spawn a research lane on that and check it?"), and only call `research(question)` once they agree — it spends real budget (plan-gated Free 5 / Plus 100 runs a month) and takes ~a minute. When it returns, present the held/rejected findings clearly and invite both people to weigh the sources, flagging any held claim that rests on a source they might not trust — that shared scrutiny is the point.',
726
726
  'PEER FIRST: other lanes may be working in the same repo as you, right now. Before starting substantive work — and before any code edit that could overlap another lane — check what the room is doing: the ROOM NOW snapshot appended to your latest turn, or read_terminal for detail; list_sessions/read_session when the question spans your other rooms. If a sibling is touching the same files or branch, coordinate (read its lane, or raise it in chat) instead of colliding.',
@@ -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,57 @@
1
+ import { pairKeyFor, pairTopic } from './cross-terminal.mjs'
2
+
3
+ // A standalone room bridge is already the authoritative process for its room. It
4
+ // must be reachable over the pair bus even when the account-level supervisor is
5
+ // not running. Keep the identity and request handling in a small tested module so
6
+ // the standalone path preserves the same pair-only and bounded-post invariants as
7
+ // account.mjs.
8
+
9
+ export function standalonePairIdentity ({ row, myUid } = {}) {
10
+ const participants = Array.isArray(row?.participants) ? row.participants.map(String) : []
11
+ const me = String(myUid || '')
12
+ if (!me || String(row?.owner_id || '') !== me || participants.length !== 2 || !participants.includes(me)) return null
13
+ const partnerUid = participants.find((uid) => uid !== me)
14
+ const key = pairKeyFor(me, partnerUid)
15
+ if (!partnerUid || !key) return null
16
+ return { partnerUid, key, topic: pairTopic(key) }
17
+ }
18
+
19
+ export function createStandalonePairResponder ({
20
+ room,
21
+ roomName = null,
22
+ host = null,
23
+ formatPeek,
24
+ receivePost,
25
+ now = () => Date.now(),
26
+ postLimit = 6,
27
+ postWindowMs = 60_000,
28
+ } = {}) {
29
+ if (!room || typeof formatPeek !== 'function' || typeof receivePost !== 'function') {
30
+ throw new TypeError('room, formatPeek, and receivePost are required')
31
+ }
32
+ let bucket = { count: 0, resetAt: 0 }
33
+
34
+ const isTarget = (payload) => String(payload?.targetRoom || '').toUpperCase().trim() === room
35
+ const allowPost = () => {
36
+ const t = now()
37
+ if (t >= bucket.resetAt) bucket = { count: 0, resetAt: t + postWindowMs }
38
+ if (bucket.count >= postLimit) return false
39
+ bucket.count += 1
40
+ return true
41
+ }
42
+
43
+ return Object.freeze({
44
+ list: (payload) => ({ nonce: payload?.nonce, rooms: [{ code: room, name: roomName, host }] }),
45
+ peek: (payload) => {
46
+ if (!isTarget(payload)) return null
47
+ try { return { nonce: payload?.nonce, text: formatPeek(payload) } }
48
+ catch { return { nonce: payload?.nonce, error: 'Could not read that session.' } }
49
+ },
50
+ post: async (payload) => {
51
+ if (!isTarget(payload)) return null
52
+ if (!allowPost()) return { nonce: payload?.nonce, error: 'Too many cross-room posts to this device right now — try again shortly.' }
53
+ try { return { nonce: payload?.nonce, ...(await receivePost(payload)) } }
54
+ catch { return { nonce: payload?.nonce, error: `Could not deliver to room ${room}.` } }
55
+ },
56
+ })
57
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.285",
3
+ "version": "0.7.287",
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": {
@@ -55,6 +55,8 @@
55
55
  "session-store.mjs",
56
56
  "side-lane.mjs",
57
57
  "cross-terminal.mjs",
58
+ "pair-bus.mjs",
59
+ "direct-pair-room.mjs",
58
60
  "lane-lifecycle.mjs",
59
61
  "interrupted-resume.mjs",
60
62
  "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
+ }