thinkpool-pair 0.7.94 → 0.7.96

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
@@ -13,9 +13,11 @@ import path from 'node:path'
13
13
  import fs from 'node:fs'
14
14
  import { createClient } from '@supabase/supabase-js'
15
15
  import os from 'node:os'
16
- import { saveAuth, loadAuth, loadDirs, bindDir } from './auth-store.mjs'
16
+ import { saveAuth, loadAuth, loadDirs, bindDir, loadServed, rememberServed } from './auth-store.mjs'
17
17
  import { isSafeToRestart } from './update-gate.mjs'
18
18
  import { makeThrottledTrack } from './presence.mjs'
19
+ import { resolveServeDir } from './serve-dir.mjs'
20
+ import { pairKeyFor, pairTopic, CROSSROOM_BUS } from './cross-terminal.mjs'
19
21
 
20
22
  const VERSION = (() => { try { return JSON.parse(fs.readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version } catch { return null } })()
21
23
 
@@ -235,7 +237,21 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
235
237
  // target room's child and relays the reply back. roomNames feeds list_sessions; pairPending
236
238
  // maps an in-flight peek's reqId → the room that asked, so the target's reply finds its way home.
237
239
  const roomNames = new Map() // room -> display name (for list_sessions)
238
- const pairPending = new Map() // reqId -> asking room code (for read_session reply routing)
240
+ // pairPending: reqId -> { reply, askerRoom }. `reply(m)` delivers a target child's
241
+ // pair-*-res back to its origin — a local asker child (Tier 1/local Tier 3) OR the
242
+ // pair bus (a partner's request, Tier 2/3). askerRoom (or null for bus origin) lets
243
+ // a dead-room cleanup drop only its own in-flight entries.
244
+ const pairPending = new Map()
245
+ // ── Tier 2/3 cross-MACHINE pair bus ─────────────────────────────────────────
246
+ // The supervisor is the IPC↔bus bridge: it joins one per-pair Supabase channel
247
+ // (tppair:<pairKey>, RLS-gated to the two real partners) per distinct partner, and
248
+ // relays peek/post requests between a local room child and the partner's supervisor.
249
+ // Spec: docs/specs/2026-06-30-cross-room-pair-bus.md
250
+ const roomPartner = new Map() // room -> the OTHER participant's uid (the pair partner), for 2-person rooms
251
+ const pairChannels = new Map() // partnerUid -> { channel, key } (one bus per distinct partner)
252
+ const busWaiters = new Map() // nonce -> { mode:'first'|'collect', resolve, timer, acc? } for OUR outbound bus requests
253
+ const postBucket = new Map() // partnerUid -> { count, resetAt } inbound cross-room-post rate limit (runaway-cost backstop)
254
+ const POST_BUCKET = { max: 6, windowMs: 60_000 } // ≤6 inbound cross-room posts / partner / minute
239
255
  let applyRequested = false // a user clicked "apply" in some room (Slice 3)
240
256
  let pendingUpdate = null // newest published version once the poll sees it
241
257
  const warned = new Set()
@@ -360,6 +376,99 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
360
376
  }
361
377
  }
362
378
 
379
+ // ── pair-bus plumbing (function declarations hoist, so order here is free) ──
380
+ // Resolve one of OUR outbound bus requests. 'first' resolves on the first reply
381
+ // (peek/post — a single partner answers); 'collect' accumulates every reply within
382
+ // the window (list — several partners may each answer) and resolves when it closes.
383
+ const resolveBus = (payload) => {
384
+ if (!payload?.nonce) return
385
+ const w = busWaiters.get(payload.nonce)
386
+ if (!w) return
387
+ if (w.mode === 'collect') { w.acc.push(payload); return }
388
+ clearTimeout(w.timer); busWaiters.delete(payload.nonce); w.resolve(payload)
389
+ }
390
+ const busSendAll = (event, payload) => {
391
+ let sent = 0
392
+ for (const { channel } of pairChannels.values()) { try { channel.send({ type: 'broadcast', event, payload }); sent++ } catch { /* channel down */ } }
393
+ return sent
394
+ }
395
+ const busRequestFirst = (event, payload, timeoutMs) => new Promise((resolve) => {
396
+ const nonce = randomUUID()
397
+ if (!busSendAll(event, { ...payload, nonce })) return resolve(null)
398
+ const timer = setTimeout(() => { busWaiters.delete(nonce); resolve(null) }, timeoutMs); timer.unref?.()
399
+ busWaiters.set(nonce, { mode: 'first', resolve, timer })
400
+ })
401
+ const busRequestCollect = (event, payload, windowMs) => new Promise((resolve) => {
402
+ const nonce = randomUUID()
403
+ if (!busSendAll(event, { ...payload, nonce })) return resolve([])
404
+ const acc = []
405
+ const timer = setTimeout(() => { busWaiters.delete(nonce); resolve(acc) }, windowMs); timer.unref?.()
406
+ busWaiters.set(nonce, { mode: 'collect', acc, resolve, timer })
407
+ })
408
+ // Inbound cross-room-post rate limit, per partner (a partner's agent posting into
409
+ // MY rooms spends MY tokens — the runaway-cost backstop, on top of the per-turn cap).
410
+ const allowInboundPost = (partnerUid) => {
411
+ const now = Date.now()
412
+ let b = postBucket.get(partnerUid)
413
+ if (!b || now >= b.resetAt) { b = { count: 0, resetAt: now + POST_BUCKET.windowMs }; postBucket.set(partnerUid, b) }
414
+ if (b.count >= POST_BUCKET.max) return false
415
+ b.count++; return true
416
+ }
417
+
418
+ // ── bus REQUEST handlers (a partner's supervisor → me). partnerUid is bound per
419
+ // channel, so a request arriving on partner P's bus is trusted as from P (the
420
+ // channel is RLS-gated to {me, P}); I only ever answer for rooms I serve AND share
421
+ // with P (roomPartner === P) — defense in depth behind the channel firewall.
422
+ const onPeerListReq = (partnerUid, ch, payload) => {
423
+ if (process.env.TP_PAIRBUS_OFF === '1') return
424
+ const rooms = [...children.keys()].filter((r) => roomPartner.get(r) === partnerUid).map((r) => ({ code: r, name: roomNames.get(r) || null, host: machine }))
425
+ try { ch.send({ type: 'broadcast', event: 'peer-list-res', payload: { nonce: payload?.nonce, rooms } }) } catch { /* channel down */ }
426
+ }
427
+ const onPeerPeekReq = (partnerUid, ch, payload) => {
428
+ const reply = (extra) => { try { ch.send({ type: 'broadcast', event: 'peer-peek-res', payload: { nonce: payload?.nonce, ...extra } }) } catch { /* channel down */ } }
429
+ if (process.env.TP_PAIRBUS_OFF === '1') return reply({ error: 'Cross-session reach is off on this device.' })
430
+ const target = String(payload?.targetRoom || '').toUpperCase().trim()
431
+ const tc = children.get(target)
432
+ if (!tc || roomPartner.get(target) !== partnerUid) return reply({ error: `Room ${target} is not a room this device shares with you.` })
433
+ const reqId = randomUUID()
434
+ pairPending.set(reqId, { reply: (mm) => reply({ text: mm.text, error: mm.error }), askerRoom: null })
435
+ try { tc.send({ t: 'pair-peek-local', reqId, terminal: payload.terminal, lines: payload.lines }) }
436
+ catch { pairPending.delete(reqId); reply({ error: `Could not reach room ${target}.` }) }
437
+ }
438
+ const onPeerPostReq = (partnerUid, ch, payload) => {
439
+ const reply = (extra) => { try { ch.send({ type: 'broadcast', event: 'peer-post-res', payload: { nonce: payload?.nonce, ...extra } }) } catch { /* channel down */ } }
440
+ if (process.env.TP_PAIRBUS_OFF === '1') return reply({ error: 'Cross-room posting is off on this device.' })
441
+ if (!allowInboundPost(partnerUid)) return reply({ error: 'Too many cross-room posts to this device right now — try again shortly.' })
442
+ const target = String(payload?.targetRoom || '').toUpperCase().trim()
443
+ const tc = children.get(target)
444
+ if (!tc || roomPartner.get(target) !== partnerUid) return reply({ error: `Room ${target} is not a room this device shares with you.` })
445
+ const reqId = randomUUID()
446
+ pairPending.set(reqId, { reply: (mm) => reply({ ok: mm.ok, ref: mm.ref, error: mm.error }), askerRoom: null })
447
+ try { tc.send({ t: 'pair-post-local', reqId, fromRoom: payload.fromRoom, fromHost: payload.fromHost, text: payload.text, terminal: payload.terminal }) }
448
+ catch { pairPending.delete(reqId); reply({ error: `Could not reach room ${target}.` }) }
449
+ }
450
+ // Join the per-pair bus for `partnerUid` (idempotent). The channel is PRIVATE so
451
+ // Realtime Authorization (RLS on realtime.messages, migration 20260630120000) gates
452
+ // both join and broadcast to the two real partners — a stranger never joins.
453
+ const subscribePairBus = (partnerUid, key) => {
454
+ if (!partnerUid || pairChannels.has(partnerUid)) return
455
+ const topic = pairTopic(key); if (!topic) return
456
+ const ch = sb.channel(topic, { config: { private: true, broadcast: { self: false } } })
457
+ ch.on('broadcast', { event: 'peer-list-req' }, ({ payload }) => onPeerListReq(partnerUid, ch, payload))
458
+ .on('broadcast', { event: 'peer-list-res' }, ({ payload }) => resolveBus(payload))
459
+ .on('broadcast', { event: 'peer-peek-req' }, ({ payload }) => onPeerPeekReq(partnerUid, ch, payload))
460
+ .on('broadcast', { event: 'peer-peek-res' }, ({ payload }) => resolveBus(payload))
461
+ .on('broadcast', { event: 'peer-post-req' }, ({ payload }) => onPeerPostReq(partnerUid, ch, payload))
462
+ .on('broadcast', { event: 'peer-post-res' }, ({ payload }) => resolveBus(payload))
463
+ .subscribe()
464
+ pairChannels.set(partnerUid, { channel: ch, key })
465
+ process.stderr.write(`\n ◆ pair bus open — cross-machine Ensemble reach with your partner.\n`)
466
+ }
467
+ // Forward a local child's cross-room request to the bus (Tier 2 peek / Tier 3 post)
468
+ // when the target room is NOT served on this machine. Used inside the child handler.
469
+ const busPeek = (targetRoom, terminal, lines) => busRequestFirst('peer-peek-req', { targetRoom, terminal, lines }, CROSSROOM_BUS.busTimeoutMs)
470
+ const busPost = (targetRoom, terminal, text, fromRoom) => busRequestFirst('peer-post-req', { targetRoom, terminal, text, fromRoom, fromHost: machine }, 180_000)
471
+
363
472
  const tick = async () => {
364
473
  // Hold the account claim before serving anything (cross-machine guard). When
365
474
  // standing by, make sure we're serving nothing and wait for the next tick.
@@ -376,20 +485,39 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
376
485
  // as a guest — spawning your machine into another owner's session that already
377
486
  // runs its own bridge. The web watches tpacct:<session-owner>, so a
378
487
  // participant's bridge is duplication the UI never even looks at.
379
- const { data } = await sb.from('code_sessions').select('code,name,last_active_at,owner_id').eq('owner_id', session.user.id).order('last_active_at', { ascending: false }).limit(20)
488
+ const { data } = await sb.from('code_sessions').select('code,name,last_active_at,owner_id,participants').eq('owner_id', session.user.id).order('last_active_at', { ascending: false }).limit(20)
380
489
  rooms = data || []
381
490
  } catch { /* transient — keep existing children, retry next tick */ }
382
491
  const dirs = loadDirs()
492
+ const served = loadServed()
383
493
  for (const r of rooms) {
384
494
  const room = r.code
385
495
  if (!room || children.has(room)) continue
386
496
  if (r.owner_id && r.owner_id !== session.user.id) continue // never serve a room we don't own (defense-in-depth behind the .eq above)
387
- const dir = dirs[room] || DEFAULT_DIR // auto-serve unbound sessions in the launch dir
497
+ // Resolve the serve dir: explicit bind > last-served memory > launch dir. REFUSE
498
+ // to auto-serve an unbound room from $HOME — that strips the agent's resume context
499
+ // (cwd-keyed SDK sessions) and shows your home folder as the repo (FVIHV1DE).
500
+ const choice = resolveServeDir({ bound: dirs[room], remembered: served[room], defaultDir: DEFAULT_DIR, home: os.homedir() })
501
+ if (choice.refuse) {
502
+ if (!warned.has(room)) {
503
+ warned.add(room)
504
+ const why = choice.reason === 'home'
505
+ ? `no project directory set — refusing to serve it from your home folder (${choice.dir}); the agent would lose its session context and the room would show "${path.basename(choice.dir)}" as the repo.`
506
+ : 'no directory to serve it from.'
507
+ process.stderr.write(`\n ◇ ${room}: ${why}\n Bind it to its repo: npx thinkpool-pair bind ${room} <repo-dir>\n`)
508
+ }
509
+ continue
510
+ }
511
+ const dir = choice.dir
388
512
  if (!fs.existsSync(dir)) {
389
513
  if (!warned.has(room)) { warned.add(room); process.stderr.write(`\n ◇ ${room}: bound directory is gone (${dir}) — re-bind it.\n`) }
390
514
  continue
391
515
  }
392
516
  warned.delete(room)
517
+ // Learn this room → dir so a later relaunch from $HOME resumes it HERE (the real
518
+ // repo) instead of the home-dir fallback. Only the auto path is remembered; an
519
+ // explicit bind already persists in dirs.json.
520
+ if (choice.fromDefault) rememberServed(room, dir)
393
521
  roomNames.set(room, r.name || null) // feed list_sessions (cross-room roster)
394
522
  process.stderr.write(`\n ◆ serving ${room}${r.name ? ` "${r.name}"` : ''} → ${dir}${dirs[room] ? '' : ' (default)'}\n`)
395
523
  // Children NEVER self-update (only the supervisor restart re-resolves
@@ -409,46 +537,98 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
409
537
  if (!m) return
410
538
  if (m.t === 'idle') { childIdle.set(room, !!m.idle); childBetween.set(room, m.between != null ? !!m.between : !!m.idle); childPeer.set(room, !!m.webPeer) }
411
539
  else if (m.t === 'apply-update') { applyRequested = true; applyIfIdle() } // user clicked the chip
412
- // ── Thinkpool Ensemble cross-ROOM routing (Tier 1, read-only) ──────────
540
+ // ── Thinkpool Ensemble cross-ROOM routing ──────────────────────────────
413
541
  // `room` here is the room that sent the message (this closure is per-child).
542
+ // A target room may be LOCAL (another child on this machine — Tier 1 peek /
543
+ // local Tier 3 post) or REMOTE (the partner's machine, over the pair bus —
544
+ // Tier 2 peek / cross-machine Tier 3 post). list merges both.
414
545
  else if (m.t === 'pair-list-req') {
415
- // The asking room's OTHER sessions on this machine (same account = "your other rooms").
416
- const off = process.env.TP_CROSSROOM_OFF === '1'
417
- const others = off ? [] : [...children.keys()].filter((r) => r !== room).map((r) => ({ code: r, name: roomNames.get(r) || null }))
418
- try { child.send({ t: 'pair-list-res', reqId: m.reqId, rooms: others, ...(off ? { error: 'Cross-session reach is turned off on this bridge.' } : {}) }) } catch { /* child gone */ }
546
+ ;(async () => {
547
+ 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 }
548
+ const local = [...children.keys()].filter((r) => r !== room).map((r) => ({ code: r, name: roomNames.get(r) || null }))
549
+ let remote = []
550
+ if (process.env.TP_PAIRBUS_OFF !== '1' && pairChannels.size) {
551
+ const replies = await busRequestCollect('peer-list-req', {}, 1200)
552
+ remote = replies.flatMap((p) => (Array.isArray(p?.rooms) ? p.rooms : []))
553
+ }
554
+ try { child.send({ t: 'pair-list-res', reqId: m.reqId, rooms: [...local, ...remote] }) } catch { /* child gone */ }
555
+ })()
419
556
  }
420
557
  else if (m.t === 'pair-peek-req') {
421
- const reply = (extra) => { try { child.send({ t: 'pair-peek-res', reqId: m.reqId, ...extra }) } catch { /* child gone */ } }
422
- if (process.env.TP_CROSSROOM_OFF === '1') return reply({ error: 'Cross-session reach is turned off on this bridge.' })
423
- const target = String(m.targetRoom || '').toUpperCase().trim()
424
- if (!target || target === room) return reply({ error: `${target || 'That'} is not another of your active sessions on this machine.` })
425
- const tc = children.get(target)
426
- if (!tc) return reply({ error: `Session ${target} is not running on this machine right now (only sessions this account is actively serving here are reachable).` })
427
- pairPending.set(m.reqId, room) // remember who asked so the reply routes home
428
- try { tc.send({ t: 'pair-peek-local', reqId: m.reqId, fromRoom: room, terminal: m.terminal, lines: m.lines }) }
429
- catch { pairPending.delete(m.reqId); reply({ error: `Could not reach session ${target}.` }) }
558
+ ;(async () => {
559
+ const reply = (extra) => { try { child.send({ t: 'pair-peek-res', reqId: m.reqId, ...extra }) } catch { /* child gone */ } }
560
+ if (process.env.TP_CROSSROOM_OFF === '1') return reply({ error: 'Cross-session reach is turned off on this bridge.' })
561
+ const target = String(m.targetRoom || '').toUpperCase().trim()
562
+ if (!target || target === room) return reply({ error: `${target || 'That'} is not another of your active sessions.` })
563
+ const tc = children.get(target)
564
+ if (tc) { // LOCAL target — forward to that child; route its reply home
565
+ const reqId = randomUUID()
566
+ pairPending.set(reqId, { reply: (mm) => reply({ text: mm.text, error: mm.error }), askerRoom: room })
567
+ try { tc.send({ t: 'pair-peek-local', reqId, terminal: m.terminal, lines: m.lines }) }
568
+ catch { pairPending.delete(reqId); reply({ error: `Could not reach session ${target}.` }) }
569
+ return
570
+ }
571
+ // REMOTE target — ask the pair bus(es).
572
+ 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.` })
573
+ const res = await busPeek(target, m.terminal, m.lines)
574
+ if (!res) return reply({ error: `Room ${target} did not respond — it may be offline, or not shared with you.` })
575
+ reply({ text: res.text, error: res.error })
576
+ })()
430
577
  }
431
- else if (m.t === 'pair-peek-res') {
432
- // A target child answered — route its reply back to the room that asked.
433
- const askerRoom = pairPending.get(m.reqId)
434
- if (askerRoom == null) return
578
+ else if (m.t === 'pair-post-req') { // Tier 3 cross-room post (sender card already approved)
579
+ ;(async () => {
580
+ const reply = (extra) => { try { child.send({ t: 'pair-post-res', reqId: m.reqId, ...extra }) } catch { /* child gone */ } }
581
+ if (process.env.TP_PAIRBUS_OFF === '1') return reply({ error: 'Cross-room posting is turned off on this bridge.' })
582
+ const target = String(m.targetRoom || '').toUpperCase().trim()
583
+ if (!target || target === room) return reply({ error: `${target || 'That'} is not another of your sessions.` })
584
+ const tc = children.get(target)
585
+ if (tc) { // LOCAL target — the target child raises its own receipt card
586
+ const reqId = randomUUID()
587
+ pairPending.set(reqId, { reply: (mm) => reply({ ok: mm.ok, ref: mm.ref, error: mm.error }), askerRoom: room })
588
+ try { tc.send({ t: 'pair-post-local', reqId, fromRoom: room, fromHost: machine, text: m.text, terminal: m.terminal }) }
589
+ catch { pairPending.delete(reqId); reply({ error: `Could not reach session ${target}.` }) }
590
+ return
591
+ }
592
+ // REMOTE target — over the pair bus.
593
+ if (!pairChannels.size) return reply({ error: `Room ${target} is not on this machine, and no pair bus is open to reach it.` })
594
+ const res = await busPost(target, m.terminal, m.text, room)
595
+ if (!res) return reply({ error: `Room ${target} did not respond to the hand-off in time.` })
596
+ reply({ ok: res.ok, ref: res.ref, error: res.error })
597
+ })()
598
+ }
599
+ else if (m.t === 'pair-peek-res' || m.t === 'pair-post-res') {
600
+ // A target child answered — deliver via the stored reply fn (to a local
601
+ // asker child, or back onto the pair bus for a partner's request).
602
+ const w = pairPending.get(m.reqId)
603
+ if (!w) return
435
604
  pairPending.delete(m.reqId)
436
- const asker = children.get(askerRoom)
437
- if (asker) { try { asker.send({ t: 'pair-peek-res', reqId: m.reqId, text: m.text, error: m.error, fromRoom: room }) } catch { /* asker gone */ } }
605
+ try { w.reply(m) } catch { /* reply path gone */ }
438
606
  }
439
607
  })
440
608
  child.on('exit', () => {
441
- children.delete(room); childIdle.delete(room); childBetween.delete(room); childPeer.delete(room); roomNames.delete(room) // re-served on the next tick
442
- // Fail any in-flight cross-room peeks waiting on (or asked by) this dead room.
443
- for (const [reqId, askerRoom] of pairPending) {
444
- if (askerRoom !== room) continue
445
- pairPending.delete(reqId)
446
- }
609
+ children.delete(room); childIdle.delete(room); childBetween.delete(room); childPeer.delete(room); roomNames.delete(room); roomPartner.delete(room) // re-served on the next tick
610
+ // Fail any in-flight cross-room peeks/posts this dead room had ASKED (bus-origin
611
+ // entries have askerRoom null and just time out on their own).
612
+ for (const [reqId, w] of pairPending) { if (w?.askerRoom === room) pairPending.delete(reqId) }
447
613
  })
448
614
  // If an update is already pending, tell the freshly-served child immediately so
449
615
  // its room shows the "update ready" chip without waiting for the next poll.
450
616
  if (pendingUpdate) { try { child.send({ t: 'update-available', version: pendingUpdate }) } catch { /* child not ready */ } }
451
617
  }
618
+ // ── reconcile pair buses (Tier 2/3) — derive each served 2-person room's partner
619
+ // and join that pair's bus. One channel per DISTINCT partner (many shared rooms →
620
+ // one bus). roomPartner feeds the bus handlers' "is this room shared with P" check.
621
+ if (process.env.TP_PAIRBUS_OFF !== '1') {
622
+ for (const r of rooms) {
623
+ if (!r?.code || !children.has(r.code)) continue // only rooms we actually serve
624
+ const parts = Array.isArray(r.participants) ? r.participants : []
625
+ const partner = parts.length === 2 ? parts.find((p) => p && p !== session.user.id) : null
626
+ if (!partner) { roomPartner.delete(r.code); continue }
627
+ roomPartner.set(r.code, partner)
628
+ const key = pairKeyFor(session.user.id, partner)
629
+ if (key) subscribePairBus(partner, key)
630
+ }
631
+ }
452
632
  pushPresence()
453
633
  }
454
634
 
@@ -464,6 +644,7 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
464
644
  keepFresh.cancel()
465
645
  releaseSingleton()
466
646
  for (const c of children.values()) { try { c.kill(sig || 'SIGTERM') } catch { /* noop */ } }
647
+ for (const { channel } of pairChannels.values()) { try { sb.removeChannel(channel) } catch { /* noop */ } } // tear down pair buses (Tier 2/3)
467
648
  // Flush the presence LEAVE before exiting so the dashboard flips to
468
649
  // "not connected" in realtime (don't fire-and-forget the untrack).
469
650
  ;(async () => { try { if (claimHeld) await sb.rpc('release_bridge', { p_bridge_id: BRIDGE_ID }) } catch { /* noop */ } try { await acct.untrack() } catch { /* noop */ } try { await sb.removeChannel(acct) } catch { /* noop */ } process.exit(code) })()
package/auth-store.mjs CHANGED
@@ -11,6 +11,11 @@ import path from 'node:path'
11
11
  const DIR = path.join(os.homedir(), '.thinkpool-pair')
12
12
  const AUTH = path.join(DIR, 'auth.json')
13
13
  const DIRS = path.join(DIR, 'dirs.json')
14
+ // served.json: per-room memory of the last real (non-home) directory a room was
15
+ // auto-served from. Distinct from dirs.json (explicit binds): this is learned, so a
16
+ // supervisor relaunched from $HOME still resumes each room in the repo it last ran in
17
+ // instead of falling back to the home folder (FVIHV1DE, 2026-06-30).
18
+ const SERVED = path.join(DIR, 'served.json')
14
19
 
15
20
  function ensureDir() { try { fs.mkdirSync(DIR, { recursive: true }) } catch { /* noop */ } }
16
21
 
@@ -41,3 +46,15 @@ export function unbindDir(room) {
41
46
  try { fs.writeFileSync(DIRS, JSON.stringify(d, null, 2)) } catch { /* noop */ }
42
47
  return d
43
48
  }
49
+
50
+ // Learned per-room serve directory (see SERVED note above). loadServed feeds the
51
+ // dir-resolution precedence; rememberServed records the dir a room was just served
52
+ // from so a later relaunch-from-home resumes it there rather than in $HOME.
53
+ export function loadServed() { try { return JSON.parse(fs.readFileSync(SERVED, 'utf8')) } catch { return {} } }
54
+ export function rememberServed(room, dir) {
55
+ const d = loadServed(); if (!room || !dir || d[room] === dir) return d
56
+ d[room] = dir
57
+ ensureDir()
58
+ try { fs.writeFileSync(SERVED, JSON.stringify(d, null, 2)) } catch { /* noop */ }
59
+ return d
60
+ }
package/bridge.mjs CHANGED
@@ -39,7 +39,7 @@ import { createClient } from '@supabase/supabase-js'
39
39
  import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk'
40
40
  import { z } from 'zod'
41
41
  import { startClaudeSession } from './claude-session.mjs'
42
- import { formatPeek, PEEK, siblingsOf, resolveSibling, crossPostDecision, CROSSPOST, spawnDecision, CROSSROOM, formatPairRoster } from './cross-terminal.mjs'
42
+ import { formatPeek, PEEK, siblingsOf, resolveSibling, crossPostDecision, CROSSPOST, spawnDecision, CROSSROOM, formatPairRoster, crossRoomPostDecision } from './cross-terminal.mjs'
43
43
  import { turnInFlight } from './update-gate.mjs'
44
44
  import { saveSession, flushSession, deleteSession, loadAll, canResume, loadPtyId, savePtyId, loadNames, saveNames, appendDurableEvents, seedDurableEvents, readDurablePage, readDurableOldestSeq } from './session-store.mjs'
45
45
  import { stampEvent, makeSeqCounter, maxSeq, seqable, capReplayEvents, chunkReplayEvents, boundEventForBroadcast, inlineImageBlocks } from './event-id.mjs'
@@ -609,13 +609,68 @@ function pairRequest(type, payload = {}, timeoutMs = 4000) {
609
609
  catch { clearTimeout(timer); pairWaiters.delete(reqId); resolve({ error: 'Could not reach the account bridge.' }) }
610
610
  })
611
611
  }
612
+ // ── Tier 3 — raise a standalone permission card on a target session ──────────
613
+ // The cross-ROOM post lands here on the RECEIVING side: a person in THIS room must
614
+ // approve the incoming task before it injects (the second half of dual consent —
615
+ // the sender's room already approved sending). Reuses the exact pending/code-perm
616
+ // plumbing the SDK uses for tool cards, so the existing code-perm broadcast resolves
617
+ // it and drainPending() fails it safe to 'deny' if the room closes. Module-scope so
618
+ // the IPC listener (which is not inside openStructured's closure) can call it.
619
+ function raiseCard(entry, req) {
620
+ return new Promise((resolve) => {
621
+ if (!entry?.pending) return resolve('deny')
622
+ const payload = { term: entry.id, id: req.id, toolName: req.toolName, input: req.input, risk: req.risk }
623
+ entry.pending.set(req.id, { resolve, payload })
624
+ bcast('code-perm-req', payload)
625
+ process.stderr.write(`\n ● incoming cross-room task for ${String(entry.id).slice(0, 8)} — approve in the room.\n`)
626
+ })
627
+ }
628
+
629
+ // Receive a cross-ROOM post (Tier 3) routed to THIS room by the supervisor. Resolve
630
+ // the target agent lane, raise the receipt card, and on approval inject the turn one
631
+ // room-hop deep (so the reached agent can read/peek but cannot post onward to a third
632
+ // room — crossRoomPostDecision blocks roomHop≥1). Returns { ok, ref } or { error }.
633
+ async function receiveCrossRoomPost({ fromRoom, fromHost, text, terminal }) {
634
+ if (process.env.TP_PAIRBUS_OFF === '1') return { error: 'Cross-room posting is turned off in this room.' }
635
+ const body = String(text || '').trim()
636
+ if (!body) return { error: 'Empty cross-room post ignored.' }
637
+ const sib = siblingsOf({ selfId: null, sessions, terms, names: termNames })
638
+ let targetId = null
639
+ if (terminal) {
640
+ const hit = resolveSibling(sib, terminal)
641
+ if (hit && hit.kind !== 'agent') return { error: `Terminal ${hit.ref} in room ${room} is a shell, not an agent — cross-room posts go to agents only.` }
642
+ targetId = hit?.id || null
643
+ } else {
644
+ const agentIds = [...sessions.keys()]
645
+ if (agentIds.length === 1) targetId = agentIds[0] // unambiguous → no need to name a lane
646
+ }
647
+ if (!targetId) {
648
+ const agents = sib.filter((x) => x.kind === 'agent')
649
+ return { error: agents.length ? `Name which agent lane in room ${room} to deliver to (${agents.map((a) => a.ref).join(', ')}).` : `No agent terminal is open in room ${room} to receive this.` }
650
+ }
651
+ const te = sessions.get(targetId)
652
+ if (!te?.session) return { error: `That terminal in room ${room} is no longer live.` }
653
+ const fromLabel = `${fromRoom}${fromHost ? ` on ${fromHost}` : ''}`
654
+ let decision = 'deny'
655
+ try { decision = await raiseCard(te, { id: randomUUID(), toolName: 'mcp__thinkpool__post_to_session', input: { session: fromRoom, host: fromHost, text: body, incoming: true }, risk: 'high' }) } catch { decision = 'deny' }
656
+ if (decision !== 'allow') return { error: `The people in room ${room} declined the incoming task from ${fromLabel}.` }
657
+ // Injected turn: one room-hop deep, fresh in-room budgets (the loop-breaker reset
658
+ // point is code-turn — a real human turn — which this is NOT, so the deeper hop sticks).
659
+ te.roomHop = 1; te.hop = (te.hop || 0) + 1; te.peekCount = 0; te.postCount = 0; te.pairPeekCount = 0; te.crossRoomPostCount = 0
660
+ const msg = `[From: room ${fromLabel} — relayed via the ThinkPool cross-room Ensemble, approved by a person in this room]\n${body}`
661
+ const evt = { kind: 'you', text: msg, by: `room ${fromRoom}`, crosspost: true }
662
+ stampEvent(evt); pushLog(te, evt); bcast('code-event', { term: targetId, evt })
663
+ try { te.session.sendTurn(msg) } catch { return { error: `Could not deliver to room ${room} — the lane may have just closed.` } }
664
+ return { ok: true, ref: String(targetId).slice(0, 8) }
665
+ }
666
+
612
667
  if (IS_ACCOUNT_CHILD) {
613
668
  // A SECOND message listener (Node allows many) dedicated to pair traffic — leaves
614
669
  // the account-child idle/token listener (bottom of file) untouched.
615
670
  process.on('message', (m) => {
616
671
  if (!m || !m.t) return
617
672
  // Reply to one of OUR outbound cross-room requests → resolve the waiter.
618
- if (m.t === 'pair-list-res' || m.t === 'pair-peek-res') {
673
+ if (m.t === 'pair-list-res' || m.t === 'pair-peek-res' || m.t === 'pair-post-res') {
619
674
  const w = pairWaiters.get(m.reqId)
620
675
  if (!w) return
621
676
  clearTimeout(w.timer); pairWaiters.delete(m.reqId)
@@ -630,6 +685,13 @@ if (IS_ACCOUNT_CHILD) {
630
685
  try { text = formatPeek({ selfId: null, sessions, terms, names: termNames, terminal: m.terminal, lines: m.lines }) }
631
686
  catch { error = 'Could not read that session.' }
632
687
  try { process.send({ t: 'pair-peek-res', reqId: m.reqId, text, error }) } catch { /* parent gone */ }
688
+ return
689
+ }
690
+ // We are the TARGET of a cross-ROOM post (Tier 3) — raise the receipt card and,
691
+ // if a person approves, inject the task. Async: the human may take a while.
692
+ if (m.t === 'pair-post-local') {
693
+ receiveCrossRoomPost(m).then((res) => { try { process.send({ t: 'pair-post-res', reqId: m.reqId, ...res }) } catch { /* parent gone */ } })
694
+ return
633
695
  }
634
696
  })
635
697
  }
@@ -1063,6 +1125,41 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy }) {
1063
1125
  return okText(res?.text || `Room ${target} returned nothing.`)
1064
1126
  },
1065
1127
  ),
1128
+ // Tier 3 cross-ROOM POST — directed, DUAL-CONSENT hand-off into another of
1129
+ // your rooms (your own on this machine, or your partner's over the pair bus).
1130
+ // Strictly bounded because it spends real model tokens in another room, maybe
1131
+ // on another person's machine (the runaway-cost class): the SENDER's room
1132
+ // approves sending (PreToolUse card, claude-session.mjs), the SUPERVISOR routes
1133
+ // it (locally over IPC or across the pair bus), and the TARGET room raises its
1134
+ // OWN receipt card — no text reaches the other agent without a person at BOTH
1135
+ // ends. crossRoomPostDecision caps it at one per turn and blocks a room reached
1136
+ // via cross-room post from posting onward. Kill-switch: TP_PAIRBUS_OFF.
1137
+ tool(
1138
+ 'post_to_session',
1139
+ 'Hand a task or message to an agent in ANOTHER of your ThinkPool Code sessions — your own room on this machine, or your partner\'s room reachable through the pair (a room code from list_sessions). Pass `session` (the room code), `text` (what to send), and optionally `terminal` (which agent lane in that room). A person in YOUR room approves sending AND a person in the TARGET room approves receiving before it is delivered. An agent reached this way cannot post onward to a third room. Read the target with read_session first, and use this sparingly — only when the people clearly want the rooms to coordinate.',
1140
+ {
1141
+ session: z.string().describe('the room code of the other session (from list_sessions)'),
1142
+ text: z.string().describe('the message or task to deliver to that room\'s agent'),
1143
+ terminal: z.string().optional().describe('ref/name of the agent lane in that room; omit if it has a single agent'),
1144
+ },
1145
+ async (args) => {
1146
+ const okText = (t) => ({ content: [{ type: 'text', text: t }] })
1147
+ // Defensive re-check (the sender card already passed in PreToolUse).
1148
+ const gate = crossRoomPostDecision({ roomHop: entry.roomHop || 0, postCount: entry.crossRoomPostCount || 0, disabled: process.env.TP_PAIRBUS_OFF === '1' })
1149
+ if (!gate.ok) return okText(gate.reason)
1150
+ const target = String(args?.session || '').toUpperCase().trim()
1151
+ if (!target) return okText('Name a room code to post to (call list_sessions to see your rooms).')
1152
+ if (target === room) return okText('That is this room — use post_to_terminal for an agent in your own room.')
1153
+ const text = String(args?.text || '').trim()
1154
+ if (!text) return okText('Nothing to send — provide the task or message text.')
1155
+ entry.crossRoomPostCount = (entry.crossRoomPostCount || 0) + 1
1156
+ // Generous timeout: the TARGET room's person must approve a receipt card,
1157
+ // which can take a while. Fails soft (never hangs the turn) past the window.
1158
+ const res = await pairRequest('pair-post-req', { targetRoom: target, terminal: args?.terminal, text, fromRoom: room }, 180_000)
1159
+ if (res?.error) return okText(res.error)
1160
+ return okText(res?.ok ? `Delivered to room ${target}${res.ref ? ` (lane ${res.ref})` : ''} — it will respond in its own room; check back with read_session.` : `Room ${target} did not accept the message.`)
1161
+ },
1162
+ ),
1066
1163
  // Tier C — directed cross-lane action. WRITES into a sibling AGENT lane.
1067
1164
  // The PreToolUse gate (claude-session.mjs) already enforced: precheck passed
1068
1165
  // + a human approved the card. This handler routes only; it re-asserts the
@@ -1191,6 +1288,9 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy }) {
1191
1288
  // Tier C precheck — the PreToolUse gate calls this BEFORE raising a card, so a
1192
1289
  // disabled/looping/over-cap post never bothers a person. Closes over `entry`.
1193
1290
  crossPostGate: () => crossPostDecision({ hop: entry.hop || 0, postCount: entry.postCount || 0, disabled: process.env.TP_CROSSPOST_OFF === '1' }),
1291
+ // Tier 3 — the SENDER-side gate for post_to_session (the receipt card lives in
1292
+ // the target room). roomHop/crossRoomPostCount reset on a real human turn (code-turn).
1293
+ crossRoomPostGate: () => crossRoomPostDecision({ roomHop: entry.roomHop || 0, postCount: entry.crossRoomPostCount || 0, disabled: process.env.TP_PAIRBUS_OFF === '1' }),
1194
1294
  env: { ...process.env, TP_MOCKUP_OUTBOX: mockupOutbox },
1195
1295
  onEvent: (evt) => {
1196
1296
  // Self-heal a stale resume — the saved SDK session expired. Reopen fresh,
@@ -1500,7 +1600,9 @@ channel
1500
1600
  s.peekCount = 0
1501
1601
  s.postCount = 0
1502
1602
  s.pairPeekCount = 0 // cross-room read budget resets with the in-room ones (Tier 1)
1603
+ s.crossRoomPostCount = 0 // Tier 3 cross-room post budget resets on a real human turn
1503
1604
  s.hop = 0
1605
+ s.roomHop = 0 // a human turn is room-hop 0 — clears any injected cross-room hop depth
1504
1606
  const text = String(payload.text)
1505
1607
  // Echo the turn so BOTH readers (and late joiners, via the log) show who
1506
1608
  // said what — UNLESS silent (an @pool fire, which renders its own line).
@@ -120,7 +120,7 @@ const simplifyBlocks = (blocks = []) => blocks.map((b) => {
120
120
  */
121
121
  const MODES = new Set(['default', 'acceptEdits', 'plan', 'bypassPermissions'])
122
122
 
123
- export function startClaudeSession({ cwd, model, resume, env, mode: initialMode = 'default', onEvent, requestPermission, mcpServers, crossPostGate }) {
123
+ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode = 'default', onEvent, requestPermission, mcpServers, crossPostGate, crossRoomPostGate }) {
124
124
  const ac = new AbortController()
125
125
  let input = makeInputStream() // `let`: auto-restart swaps in a fresh stream
126
126
  let sessionId = resume || null
@@ -253,10 +253,12 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
253
253
  if (toolName === 'mcp__thinkpool__read_terminal') {
254
254
  return { continue: true, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow', permissionDecisionReason: 'Auto-approved (read-only ThinkPool cross-terminal read).' } }
255
255
  }
256
- // ThinkPool cross-ROOM READ (Tier 1: list_sessions / read_session) is read-only and
257
- // reaches ONLY the account's own other rooms on this machine (the supervisor only
258
- // serves owned rooms) — same trust class as read_terminal, so auto-allow. Posting
259
- // across rooms is a later tier and will gate on a card at both ends.
256
+ // ThinkPool cross-ROOM READ (list_sessions / read_session) is read-only. It reaches
257
+ // the account's own other rooms on this machine (Tier 1) and the partner's rooms on
258
+ // the pair bus (Tier 2) — both firewalled server-side (the supervisor only serves
259
+ // owned rooms; the pair channel is RLS-gated to the two real partners), so it is the
260
+ // same trust class as read_terminal → auto-allow. POSTING across rooms
261
+ // (post_to_session) is NOT read-only — it gates on a card at both ends below.
260
262
  if (toolName === 'mcp__thinkpool__list_sessions' || toolName === 'mcp__thinkpool__read_session') {
261
263
  return { continue: true, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow', permissionDecisionReason: 'Auto-approved (read-only ThinkPool cross-session read — your own rooms).' } }
262
264
  }
@@ -301,6 +303,21 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
301
303
  const allowed = decision === 'allow'
302
304
  return { continue: true, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: allowed ? 'allow' : 'deny', permissionDecisionReason: allowed ? 'Cross-terminal post approved in the ThinkPool room.' : 'Cross-terminal post denied in the room — do not retry; ask what to do instead.' } }
303
305
  }
306
+ // ThinkPool cross-ROOM POST (Tier 3) WRITES into ANOTHER room's agent — possibly
307
+ // on another person's machine. This is the SENDER-side gate (the recipient room
308
+ // raises its OWN receipt card before anything injects — dual consent). Same shape
309
+ // as post_to_terminal: a bridge-side precheck (kill-switch / roomHop / per-turn
310
+ // cap) so a blocked post never raises a card, then a 'high'-risk human card here.
311
+ if (toolName === 'mcp__thinkpool__post_to_session') {
312
+ const gate = crossRoomPostGate ? crossRoomPostGate() : { ok: true }
313
+ if (!gate.ok) {
314
+ return { continue: true, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: gate.reason || 'Cross-room post not allowed right now.' } }
315
+ }
316
+ let decision = 'deny'
317
+ try { decision = await requestPermission?.({ id: randomUUID(), toolName, input: toolInput, risk: 'high' }) ?? 'deny' } catch { decision = 'deny' }
318
+ const allowed = decision === 'allow'
319
+ return { continue: true, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: allowed ? 'allow' : 'deny', permissionDecisionReason: allowed ? 'Cross-room post approved in the sending room — the target room will be asked to accept it.' : 'Cross-room post denied in the room — do not retry; ask what to do instead.' } }
320
+ }
304
321
  // ── Plan approval — ExitPlanMode is how the agent presents its plan in
305
322
  // plan mode. Render a dedicated plan card (not the generic perm card) with
306
323
  // three outcomes: run (exit → default), accept (exit → acceptEdits), keep
@@ -420,7 +437,8 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
420
437
  'CROSS-TERMINAL AWARENESS: this room may have other terminals open alongside yours — other agents working, or shells the people are driving. You have a READ-ONLY tool, read_terminal: call it with no arguments to list the other open terminals, or with a terminal ref/id/command to read that terminal\'s recent activity. Reach for it when your work depends on what another terminal is doing (e.g. someone says "see what the other terminal hit", or you need to coordinate with a sibling agent before acting). It only ever reads — it never changes another terminal. Identify a terminal by its NAME or its ref/id from the roster, never by an on-screen number like "Terminal 2" — those positional labels renumber when a terminal is closed, so they do not reliably point at a lane.',
421
438
  '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.',
422
439
  'CROSS-TERMINAL SPAWN: when a person asks you to fan work out across multiple agents — research lanes, parallel sub-tasks, a swarm — open YOUR OWN fresh lanes with spawn_terminal(name?, task?, model?); do NOT dump the work into siblings that are already busy (a sibling is mid-task unless read_terminal shows it idle, and hijacking it derails their work). spawn_terminal opens a brand-new Claude lane and, if you pass `task`, hands it that task immediately; it appears as a new terminal in the room. A spawned lane INHERITS your current permission mode by default — so if you are in bypass, your lanes run autonomously with no per-action clicking; pass `mode` only to override. Raising a lane to bypass from a non-bypass lane asks the room to confirm once (everything else spawns without a prompt). Collect each lane\'s result later with read_terminal (a spawned lane cannot post back to you), and tidy up with close_terminal once you have what you need — you may only close lanes you spawned yourself. You are bounded: a few new lanes per turn, a per-plan dispatch ceiling on concurrent spawned lanes (Free 3 / Plus 6) that is separate from the human terminals so it never blocks them, an overall room machine cap, and a lane you were spawned/cross-posted into cannot itself spawn (so no runaway). This is how you "open the terminals yourself" instead of asking a person to.',
423
- 'CROSS-SESSION AWARENESS: the Ensemble reaches across your SESSIONS, not just the terminals in this room. You have two READ-ONLY tools for your OTHER ThinkPool Code rooms running on this machine: list_sessions() lists them (room code + name), and read_session(session, terminal?) reads recent activity inside one (omit `terminal` to list that room\'s terminals, or pass a ref/name from it to read that lane). Reach for them when work spans your rooms — "what\'s the other project up to", "pick up where the other session left off", or to check a long-running task in another room before you act here. They only ever read — they never change another session, and they reach ONLY your own rooms (never a stranger\'s). This works only when your sessions run under the ThinkPool account bridge; a standalone room sees just its own terminals. Posting work INTO another session is not available yet — for now, read across sessions and coordinate with the people in the room.',
440
+ '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.',
441
+ '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.',
424
442
  'WRITE PLANS INTO THE CHAT: whenever you form or revise a plan — because the room is in Plan mode, or because someone asked you to plan, design, or think it through first — write the actual plan out as a normal message in the room as you develop it: the approach, the concrete steps, the files you will touch, the open questions. The room does NOT surface plan files at all, and the plan-approval card does not reliably carry the plan text, so a plan that lives only in a plan file or only inside ExitPlanMode is INVISIBLE to the people you are working with — they just see "plan ready" with no content. The chat is the canonical place your plan lives; put it there so the room can read and react to it before you proceed.',
425
443
  ].join(' '),
426
444
  // Needed for live thinking-token progress (SDKThinkingTokensMessage) to
@@ -225,7 +225,62 @@ export const formatPairRoster = (rooms, { thisRoom, error } = {}) => {
225
225
  if (!r || !r.code || r.code === thisRoom || seen.has(r.code)) return false
226
226
  seen.add(r.code); return true
227
227
  })
228
- if (!list.length) return 'No other sessions of yours are open on this machine right now. (Cross-session reach is limited to rooms this account is actively running here.)'
229
- const rows = list.map((r) => `- ${r.code}${r.name ? ` · "${r.name}"` : ''}`)
230
- return `Your other ThinkPool Code sessions on this machine (call read_session with a room code to look inside one):\n${rows.join('\n')}`
228
+ if (!list.length) return 'No other sessions of yours are open right now. (Cross-session reach is limited to rooms this account is running here, plus your partner\'s rooms in the same pair.)'
229
+ // `host` is set for a Tier-2 cross-MACHINE room (it lives on a partner's Mac,
230
+ // reached over the pair bus); a same-machine Tier-1 room has none. Rendering the
231
+ // host tells the agent (and the people) which rooms are "over there".
232
+ const rows = list.map((r) => `- ${r.code}${r.name ? ` · "${r.name}"` : ''}${r.host ? ` · on ${r.host}` : ''}`)
233
+ return `Your other ThinkPool Code sessions (call read_session with a room code to look inside one):\n${rows.join('\n')}`
234
+ }
235
+
236
+ // ── Tier 2 cross-MACHINE peek — the pair bus ─────────────────────────────────
237
+ // Tier 1 reached the account's OTHER rooms on the SAME machine over Node IPC.
238
+ // Tier 2 reaches a PARTNER's rooms on ANOTHER machine over a per-pair Supabase
239
+ // Broadcast channel, `tppair:<pairKey>`. The firewall IS the channel: only the
240
+ // two people who actually share a 2-person room can join it (RLS on
241
+ // realtime.messages, server-side) — so a supervisor never even hears a stranger's
242
+ // room, and there's no client-side trust to get wrong. pairKeyFor builds the
243
+ // IDENTICAL channel name on both Macs from the two participant UUIDs, so they meet
244
+ // on the same bus without coordinating. Spec: docs/specs/2026-06-30-cross-room-pair-bus.md
245
+ export const CROSSROOM_BUS = {
246
+ busTimeoutMs: 4000, // a cross-MACHINE round-trip fails soft after this (never hangs a turn)
247
+ }
248
+
249
+ // Deterministic, order-INDEPENDENT channel key for the pair {a, b}: lowercased,
250
+ // sorted, dot-joined. Both Macs derive the same string from the shared room's two
251
+ // participants, so they subscribe to the same tppair channel. Returns null unless
252
+ // given two DISTINCT uuids — a self-only "pair" (one participant) has no partner on
253
+ // another machine to reach, so it gets no bus.
254
+ export const pairKeyFor = (a, b) => {
255
+ const x = String(a == null ? '' : a).toLowerCase().trim()
256
+ const y = String(b == null ? '' : b).toLowerCase().trim()
257
+ if (!x || !y || x === y) return null
258
+ return x < y ? `${x}.${y}` : `${y}.${x}`
259
+ }
260
+
261
+ // The realtime topic both supervisors subscribe to for a pair. The two UUIDs in the
262
+ // name are not secrets (they're already in tpacct:<uid>), and the RLS policy re-derives
263
+ // + verifies membership server-side; the name is just the rendezvous point.
264
+ export const pairTopic = (key) => key ? `tppair:${key}` : null
265
+
266
+ // ── Tier 3 cross-ROOM POST — directed, dual-consent, possibly cross-machine ──
267
+ // Posting work INTO another room (yours on this machine, or a partner's over the
268
+ // pair bus). Deliberately TIGHTER than the in-room cross-TERMINAL post (CROSSPOST):
269
+ // it spends real model tokens in another room — maybe on another person's machine —
270
+ // so (a) the per-turn cap starts at ONE, (b) a room reached VIA a cross-room post
271
+ // cannot post onward (roomHop loop-breaker), and (c) BOTH ends gate on a person:
272
+ // the sender's room approves the send, the recipient's room approves the receipt.
273
+ // The bridge enforces (a)+(b) here; the dual cards live in the gate + the web.
274
+ // Kill-switch: TP_PAIRBUS_OFF (the same switch that disables the bus).
275
+ export const CROSSROOM_POST = {
276
+ maxRoomHop: 1, // human turn = roomHop 0; a turn injected via cross-room post = 1, which CANNOT post onward
277
+ crossRoomPostCap: 1, // post_to_session calls allowed per human turn (tighter than in-room CROSSPOST.perTurnCap)
278
+ }
279
+
280
+ export const crossRoomPostDecision = ({ roomHop = 0, postCount = 0, disabled = false } = {}, limits = CROSSROOM_POST) => {
281
+ const o = { ...CROSSROOM_POST, ...(limits || {}) }
282
+ if (disabled) return { ok: false, reason: 'Cross-room posting is turned off on this bridge.' }
283
+ if (roomHop >= o.maxRoomHop) return { ok: false, reason: 'Cross-room hop limit reached — a room reached via a cross-room post cannot itself post into another room; a person must direct the next cross-room action.' }
284
+ if (postCount >= o.crossRoomPostCap) return { ok: false, reason: `Cross-room post limit reached for this turn (${o.crossRoomPostCap}).` }
285
+ return { ok: true }
231
286
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.94",
3
+ "version": "0.7.96",
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": {
@@ -16,6 +16,7 @@
16
16
  "transcript-sanitize.mjs",
17
17
  "session-store.mjs",
18
18
  "cross-terminal.mjs",
19
+ "serve-dir.mjs",
19
20
  "service.mjs",
20
21
  "presence.mjs",
21
22
  "account.mjs",
package/serve-dir.mjs ADDED
@@ -0,0 +1,40 @@
1
+ /* serve-dir.mjs — decide which working directory the account supervisor serves a
2
+ room from, and REFUSE the home-directory trap.
3
+
4
+ Why this exists (FVIHV1DE, 2026-06-30): the supervisor auto-serves an unbound room
5
+ from its own launch dir (DEFAULT_DIR = process.cwd()). When the supervisor is
6
+ relaunched from $HOME instead of the repo, every unbound room is served from $HOME.
7
+ That silently breaks two things:
8
+ 1. The agent's cwd becomes ~, so the Claude SDK (which keys a session's transcript
9
+ by project directory) can't find the session that was created under the real
10
+ repo → resume fails → the agent is BLIND to its own history (the UI still replays
11
+ the persisted ThinkPool log, so "I see the chat but the agent can't").
12
+ 2. The room's repo label is basename(cwd) = your home folder name ("max"/"conrad")
13
+ instead of "thinkpool" / a branch.
14
+ Loud refusal beats silent context corruption. Precedence:
15
+ explicit bind > last-served memory > the supervisor's launch dir
16
+ and an UNBOUND, never-served room is refused when the launch dir IS $HOME.
17
+ Pure (no fs/process) so it unit-tests without spawning bridges. */
18
+ import path from 'node:path'
19
+
20
+ const samePath = (a, b) => {
21
+ try { return !!a && !!b && path.resolve(a) === path.resolve(b) } catch { return false }
22
+ }
23
+
24
+ // Returns { dir, refuse, reason, fromDefault }.
25
+ // - dir: the directory to serve from (even when refusing, for the message)
26
+ // - refuse: true → do NOT serve; warn the user to bind instead
27
+ // - reason: 'home' | 'none' when refusing
28
+ // - fromDefault:true → resolved to the supervisor's launch dir with no bind/memory
29
+ // (the caller should remember it after a successful serve, so a later
30
+ // relaunch-from-home resumes the room from this same repo)
31
+ export function resolveServeDir({ bound, remembered, defaultDir, home } = {}) {
32
+ const pick = bound || remembered || defaultDir || null
33
+ if (!pick) return { dir: null, refuse: true, reason: 'none' }
34
+ // Only the AUTO path (no explicit bind, no remembered dir) is guarded — if a person
35
+ // deliberately bound a room to their home dir, that's their call, honour it.
36
+ if (!bound && !remembered && samePath(pick, home)) {
37
+ return { dir: pick, refuse: true, reason: 'home' }
38
+ }
39
+ return { dir: pick, refuse: false, fromDefault: !bound && !remembered }
40
+ }