thinkpool-pair 0.7.95 → 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 +189 -28
- package/bridge.mjs +104 -2
- package/claude-session.mjs +24 -6
- package/cross-terminal.mjs +58 -3
- package/package.json +1 -1
package/account.mjs
CHANGED
|
@@ -17,6 +17,7 @@ import { saveAuth, loadAuth, loadDirs, bindDir, loadServed, rememberServed } fro
|
|
|
17
17
|
import { isSafeToRestart } from './update-gate.mjs'
|
|
18
18
|
import { makeThrottledTrack } from './presence.mjs'
|
|
19
19
|
import { resolveServeDir } from './serve-dir.mjs'
|
|
20
|
+
import { pairKeyFor, pairTopic, CROSSROOM_BUS } from './cross-terminal.mjs'
|
|
20
21
|
|
|
21
22
|
const VERSION = (() => { try { return JSON.parse(fs.readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version } catch { return null } })()
|
|
22
23
|
|
|
@@ -236,7 +237,21 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
236
237
|
// target room's child and relays the reply back. roomNames feeds list_sessions; pairPending
|
|
237
238
|
// maps an in-flight peek's reqId → the room that asked, so the target's reply finds its way home.
|
|
238
239
|
const roomNames = new Map() // room -> display name (for list_sessions)
|
|
239
|
-
|
|
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
|
|
240
255
|
let applyRequested = false // a user clicked "apply" in some room (Slice 3)
|
|
241
256
|
let pendingUpdate = null // newest published version once the poll sees it
|
|
242
257
|
const warned = new Set()
|
|
@@ -361,6 +376,99 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
361
376
|
}
|
|
362
377
|
}
|
|
363
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
|
+
|
|
364
472
|
const tick = async () => {
|
|
365
473
|
// Hold the account claim before serving anything (cross-machine guard). When
|
|
366
474
|
// standing by, make sure we're serving nothing and wait for the next tick.
|
|
@@ -377,7 +485,7 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
377
485
|
// as a guest — spawning your machine into another owner's session that already
|
|
378
486
|
// runs its own bridge. The web watches tpacct:<session-owner>, so a
|
|
379
487
|
// participant's bridge is duplication the UI never even looks at.
|
|
380
|
-
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)
|
|
381
489
|
rooms = data || []
|
|
382
490
|
} catch { /* transient — keep existing children, retry next tick */ }
|
|
383
491
|
const dirs = loadDirs()
|
|
@@ -429,46 +537,98 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
429
537
|
if (!m) return
|
|
430
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) }
|
|
431
539
|
else if (m.t === 'apply-update') { applyRequested = true; applyIfIdle() } // user clicked the chip
|
|
432
|
-
// ── Thinkpool Ensemble cross-ROOM routing
|
|
540
|
+
// ── Thinkpool Ensemble cross-ROOM routing ──────────────────────────────
|
|
433
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.
|
|
434
545
|
else if (m.t === 'pair-list-req') {
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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
|
+
})()
|
|
439
556
|
}
|
|
440
557
|
else if (m.t === 'pair-peek-req') {
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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
|
+
})()
|
|
577
|
+
}
|
|
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
|
+
})()
|
|
450
598
|
}
|
|
451
|
-
else if (m.t === 'pair-peek-res') {
|
|
452
|
-
// A target child answered —
|
|
453
|
-
|
|
454
|
-
|
|
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
|
|
455
604
|
pairPending.delete(m.reqId)
|
|
456
|
-
|
|
457
|
-
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 */ }
|
|
458
606
|
}
|
|
459
607
|
})
|
|
460
608
|
child.on('exit', () => {
|
|
461
|
-
children.delete(room); childIdle.delete(room); childBetween.delete(room); childPeer.delete(room); roomNames.delete(room) // re-served on the next tick
|
|
462
|
-
// Fail any in-flight cross-room peeks
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
pairPending.delete(reqId)
|
|
466
|
-
}
|
|
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) }
|
|
467
613
|
})
|
|
468
614
|
// If an update is already pending, tell the freshly-served child immediately so
|
|
469
615
|
// its room shows the "update ready" chip without waiting for the next poll.
|
|
470
616
|
if (pendingUpdate) { try { child.send({ t: 'update-available', version: pendingUpdate }) } catch { /* child not ready */ } }
|
|
471
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
|
+
}
|
|
472
632
|
pushPresence()
|
|
473
633
|
}
|
|
474
634
|
|
|
@@ -484,6 +644,7 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
484
644
|
keepFresh.cancel()
|
|
485
645
|
releaseSingleton()
|
|
486
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)
|
|
487
648
|
// Flush the presence LEAVE before exiting so the dashboard flips to
|
|
488
649
|
// "not connected" in realtime (don't fire-and-forget the untrack).
|
|
489
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/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).
|
package/claude-session.mjs
CHANGED
|
@@ -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 (
|
|
257
|
-
//
|
|
258
|
-
//
|
|
259
|
-
//
|
|
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.
|
|
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
|
package/cross-terminal.mjs
CHANGED
|
@@ -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
|
|
229
|
-
|
|
230
|
-
|
|
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
|
}
|