thinkpool-pair 0.7.95 → 0.7.97

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
@@ -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
- 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
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 (Tier 1, read-only) ──────────
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
- // The asking room's OTHER sessions on this machine (same account = "your other rooms").
436
- const off = process.env.TP_CROSSROOM_OFF === '1'
437
- const others = off ? [] : [...children.keys()].filter((r) => r !== room).map((r) => ({ code: r, name: roomNames.get(r) || null }))
438
- 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
+ })()
439
556
  }
440
557
  else if (m.t === 'pair-peek-req') {
441
- const reply = (extra) => { try { child.send({ t: 'pair-peek-res', reqId: m.reqId, ...extra }) } catch { /* child gone */ } }
442
- if (process.env.TP_CROSSROOM_OFF === '1') return reply({ error: 'Cross-session reach is turned off on this bridge.' })
443
- const target = String(m.targetRoom || '').toUpperCase().trim()
444
- if (!target || target === room) return reply({ error: `${target || 'That'} is not another of your active sessions on this machine.` })
445
- const tc = children.get(target)
446
- 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).` })
447
- pairPending.set(m.reqId, room) // remember who asked so the reply routes home
448
- try { tc.send({ t: 'pair-peek-local', reqId: m.reqId, fromRoom: room, terminal: m.terminal, lines: m.lines }) }
449
- 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
+ })()
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 — route its reply back to the room that asked.
453
- const askerRoom = pairPending.get(m.reqId)
454
- if (askerRoom == null) return
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
- const asker = children.get(askerRoom)
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 waiting on (or asked by) this dead room.
463
- for (const [reqId, askerRoom] of pairPending) {
464
- if (askerRoom !== room) continue
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
@@ -32,14 +32,16 @@ import os from 'node:os'
32
32
  import fs from 'node:fs'
33
33
  import path from 'node:path'
34
34
  import readline from 'node:readline'
35
- import { spawn, execSync } from 'node:child_process'
35
+ import { spawn, execSync, execFileSync } from 'node:child_process'
36
36
  import { fileURLToPath } from 'node:url'
37
37
  import { randomUUID } from 'node:crypto'
38
38
  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 { FLOW_CONDUCTOR_PROMPT, FLOW_LANE_PROMPT, buildConductorEnv } from './flow-conductor.mjs'
43
+ import { createFlowWorktree } from './flow-worktree.mjs'
44
+ import { formatPeek, PEEK, siblingsOf, resolveSibling, crossPostDecision, CROSSPOST, spawnDecision, CROSSROOM, formatPairRoster, crossRoomPostDecision } from './cross-terminal.mjs'
43
45
  import { turnInFlight } from './update-gate.mjs'
44
46
  import { saveSession, flushSession, deleteSession, loadAll, canResume, loadPtyId, savePtyId, loadNames, saveNames, appendDurableEvents, seedDurableEvents, readDurablePage, readDurableOldestSeq } from './session-store.mjs'
45
47
  import { stampEvent, makeSeqCounter, maxSeq, seqable, capReplayEvents, chunkReplayEvents, boundEventForBroadcast, inlineImageBlocks } from './event-id.mjs'
@@ -609,13 +611,68 @@ function pairRequest(type, payload = {}, timeoutMs = 4000) {
609
611
  catch { clearTimeout(timer); pairWaiters.delete(reqId); resolve({ error: 'Could not reach the account bridge.' }) }
610
612
  })
611
613
  }
614
+ // ── Tier 3 — raise a standalone permission card on a target session ──────────
615
+ // The cross-ROOM post lands here on the RECEIVING side: a person in THIS room must
616
+ // approve the incoming task before it injects (the second half of dual consent —
617
+ // the sender's room already approved sending). Reuses the exact pending/code-perm
618
+ // plumbing the SDK uses for tool cards, so the existing code-perm broadcast resolves
619
+ // it and drainPending() fails it safe to 'deny' if the room closes. Module-scope so
620
+ // the IPC listener (which is not inside openStructured's closure) can call it.
621
+ function raiseCard(entry, req) {
622
+ return new Promise((resolve) => {
623
+ if (!entry?.pending) return resolve('deny')
624
+ const payload = { term: entry.id, id: req.id, toolName: req.toolName, input: req.input, risk: req.risk }
625
+ entry.pending.set(req.id, { resolve, payload })
626
+ bcast('code-perm-req', payload)
627
+ process.stderr.write(`\n ● incoming cross-room task for ${String(entry.id).slice(0, 8)} — approve in the room.\n`)
628
+ })
629
+ }
630
+
631
+ // Receive a cross-ROOM post (Tier 3) routed to THIS room by the supervisor. Resolve
632
+ // the target agent lane, raise the receipt card, and on approval inject the turn one
633
+ // room-hop deep (so the reached agent can read/peek but cannot post onward to a third
634
+ // room — crossRoomPostDecision blocks roomHop≥1). Returns { ok, ref } or { error }.
635
+ async function receiveCrossRoomPost({ fromRoom, fromHost, text, terminal }) {
636
+ if (process.env.TP_PAIRBUS_OFF === '1') return { error: 'Cross-room posting is turned off in this room.' }
637
+ const body = String(text || '').trim()
638
+ if (!body) return { error: 'Empty cross-room post ignored.' }
639
+ const sib = siblingsOf({ selfId: null, sessions, terms, names: termNames })
640
+ let targetId = null
641
+ if (terminal) {
642
+ const hit = resolveSibling(sib, terminal)
643
+ 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.` }
644
+ targetId = hit?.id || null
645
+ } else {
646
+ const agentIds = [...sessions.keys()]
647
+ if (agentIds.length === 1) targetId = agentIds[0] // unambiguous → no need to name a lane
648
+ }
649
+ if (!targetId) {
650
+ const agents = sib.filter((x) => x.kind === 'agent')
651
+ 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.` }
652
+ }
653
+ const te = sessions.get(targetId)
654
+ if (!te?.session) return { error: `That terminal in room ${room} is no longer live.` }
655
+ const fromLabel = `${fromRoom}${fromHost ? ` on ${fromHost}` : ''}`
656
+ let decision = 'deny'
657
+ 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' }
658
+ if (decision !== 'allow') return { error: `The people in room ${room} declined the incoming task from ${fromLabel}.` }
659
+ // Injected turn: one room-hop deep, fresh in-room budgets (the loop-breaker reset
660
+ // point is code-turn — a real human turn — which this is NOT, so the deeper hop sticks).
661
+ te.roomHop = 1; te.hop = (te.hop || 0) + 1; te.peekCount = 0; te.postCount = 0; te.pairPeekCount = 0; te.crossRoomPostCount = 0
662
+ const msg = `[From: room ${fromLabel} — relayed via the ThinkPool cross-room Ensemble, approved by a person in this room]\n${body}`
663
+ const evt = { kind: 'you', text: msg, by: `room ${fromRoom}`, crosspost: true }
664
+ stampEvent(evt); pushLog(te, evt); bcast('code-event', { term: targetId, evt })
665
+ try { te.session.sendTurn(msg) } catch { return { error: `Could not deliver to room ${room} — the lane may have just closed.` } }
666
+ return { ok: true, ref: String(targetId).slice(0, 8) }
667
+ }
668
+
612
669
  if (IS_ACCOUNT_CHILD) {
613
670
  // A SECOND message listener (Node allows many) dedicated to pair traffic — leaves
614
671
  // the account-child idle/token listener (bottom of file) untouched.
615
672
  process.on('message', (m) => {
616
673
  if (!m || !m.t) return
617
674
  // Reply to one of OUR outbound cross-room requests → resolve the waiter.
618
- if (m.t === 'pair-list-res' || m.t === 'pair-peek-res') {
675
+ if (m.t === 'pair-list-res' || m.t === 'pair-peek-res' || m.t === 'pair-post-res') {
619
676
  const w = pairWaiters.get(m.reqId)
620
677
  if (!w) return
621
678
  clearTimeout(w.timer); pairWaiters.delete(m.reqId)
@@ -630,6 +687,13 @@ if (IS_ACCOUNT_CHILD) {
630
687
  try { text = formatPeek({ selfId: null, sessions, terms, names: termNames, terminal: m.terminal, lines: m.lines }) }
631
688
  catch { error = 'Could not read that session.' }
632
689
  try { process.send({ t: 'pair-peek-res', reqId: m.reqId, text, error }) } catch { /* parent gone */ }
690
+ return
691
+ }
692
+ // We are the TARGET of a cross-ROOM post (Tier 3) — raise the receipt card and,
693
+ // if a person approves, inject the task. Async: the human may take a while.
694
+ if (m.t === 'pair-post-local') {
695
+ receiveCrossRoomPost(m).then((res) => { try { process.send({ t: 'pair-post-res', reqId: m.reqId, ...res }) } catch { /* parent gone */ } })
696
+ return
633
697
  }
634
698
  })
635
699
  }
@@ -953,12 +1017,12 @@ function restoredTurnOpen(log) {
953
1017
  // relay STRUCTURED events. onEvent → broadcast `code-event` + print locally +
954
1018
  // persist to the host file; tool calls round-trip through the perm card; the
955
1019
  // rolling log replays to joiners and survives bridge restarts (session-store).
956
- function openStructured({ id, model, resume, log, commands, mode, spawnedBy }) {
1020
+ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rolePrompt, flowSessionId, flowTaskKey, cwd }) {
957
1021
  if (sessions.has(id)) return
958
1022
  // spawnedBy: set when this lane was Dispatched (spawn_terminal). Restored from the
959
1023
  // session store so the Ensemble flag survives a bridge restart (else a respin
960
1024
  // stripped it and the lane reverted to a plain tab — the t6 "no chip" bug).
961
- const entry = { cmd: 'claude', kind: 'structured', log: Array.isArray(log) ? log.slice(-STRUCTURED_LOG_MAX) : [], pending: new Map(), session: null, recovered: false, commands: Array.isArray(commands) ? commands : undefined, mode, spawnedBy: spawnedBy || undefined }
1025
+ const entry = { cmd: 'claude', kind: 'structured', log: Array.isArray(log) ? log.slice(-STRUCTURED_LOG_MAX) : [], pending: new Map(), session: null, recovered: false, commands: Array.isArray(commands) ? commands : undefined, mode, spawnedBy: spawnedBy || undefined, flowSessionId: flowSessionId || null, flowTaskKey: flowTaskKey || null, cwd: cwd || null }
962
1026
  // C1 (RT-2): per-term contiguous seq counter. Seeded from the restored log's max
963
1027
  // so a bridge restart resumes ABOVE every persisted seq — fresh events never reuse
964
1028
  // an old seq, which is what keeps the between-turns restart dup-free (see
@@ -1063,6 +1127,41 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy }) {
1063
1127
  return okText(res?.text || `Room ${target} returned nothing.`)
1064
1128
  },
1065
1129
  ),
1130
+ // Tier 3 cross-ROOM POST — directed, DUAL-CONSENT hand-off into another of
1131
+ // your rooms (your own on this machine, or your partner's over the pair bus).
1132
+ // Strictly bounded because it spends real model tokens in another room, maybe
1133
+ // on another person's machine (the runaway-cost class): the SENDER's room
1134
+ // approves sending (PreToolUse card, claude-session.mjs), the SUPERVISOR routes
1135
+ // it (locally over IPC or across the pair bus), and the TARGET room raises its
1136
+ // OWN receipt card — no text reaches the other agent without a person at BOTH
1137
+ // ends. crossRoomPostDecision caps it at one per turn and blocks a room reached
1138
+ // via cross-room post from posting onward. Kill-switch: TP_PAIRBUS_OFF.
1139
+ tool(
1140
+ 'post_to_session',
1141
+ '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.',
1142
+ {
1143
+ session: z.string().describe('the room code of the other session (from list_sessions)'),
1144
+ text: z.string().describe('the message or task to deliver to that room\'s agent'),
1145
+ terminal: z.string().optional().describe('ref/name of the agent lane in that room; omit if it has a single agent'),
1146
+ },
1147
+ async (args) => {
1148
+ const okText = (t) => ({ content: [{ type: 'text', text: t }] })
1149
+ // Defensive re-check (the sender card already passed in PreToolUse).
1150
+ const gate = crossRoomPostDecision({ roomHop: entry.roomHop || 0, postCount: entry.crossRoomPostCount || 0, disabled: process.env.TP_PAIRBUS_OFF === '1' })
1151
+ if (!gate.ok) return okText(gate.reason)
1152
+ const target = String(args?.session || '').toUpperCase().trim()
1153
+ if (!target) return okText('Name a room code to post to (call list_sessions to see your rooms).')
1154
+ if (target === room) return okText('That is this room — use post_to_terminal for an agent in your own room.')
1155
+ const text = String(args?.text || '').trim()
1156
+ if (!text) return okText('Nothing to send — provide the task or message text.')
1157
+ entry.crossRoomPostCount = (entry.crossRoomPostCount || 0) + 1
1158
+ // Generous timeout: the TARGET room's person must approve a receipt card,
1159
+ // which can take a while. Fails soft (never hangs the turn) past the window.
1160
+ const res = await pairRequest('pair-post-req', { targetRoom: target, terminal: args?.terminal, text, fromRoom: room }, 180_000)
1161
+ if (res?.error) return okText(res.error)
1162
+ 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.`)
1163
+ },
1164
+ ),
1066
1165
  // Tier C — directed cross-lane action. WRITES into a sibling AGENT lane.
1067
1166
  // The PreToolUse gate (claude-session.mjs) already enforced: precheck passed
1068
1167
  // + a human approved the card. This handler routes only; it re-asserts the
@@ -1183,15 +1282,36 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy }) {
1183
1282
  return okText(`Closed agent lane ${target.ref}${target.name ? ` ("${target.name}")` : ''}.`)
1184
1283
  },
1185
1284
  ),
1285
+ // Flow lane → mark this slice done (lane-done return path). Reads the lane's
1286
+ // worktree HEAD as commit_sha (the atomic-revert target) + broadcasts
1287
+ // flow-task-done → the room flips the task done + dispatches the next wave
1288
+ // (slices whose deps just got satisfied).
1289
+ tool(
1290
+ 'mark_flow_done',
1291
+ "ThinkPool Flow ONLY — call this ONCE when your slice is built, RUNS, and meets its acceptance criteria. Records your latest commit as the slice's atomic-revert target and signals the room that this slice is done (which unblocks slices that depended on you). No arguments: your commit is read from your worktree. Do not call before your slice actually runs + meets acceptance.",
1292
+ {},
1293
+ async () => {
1294
+ const okText = (t) => ({ content: [{ type: 'text', text: t }] })
1295
+ if (!entry.flowSessionId || !entry.flowTaskKey) return okText('Not a Flow lane — nothing to mark done.')
1296
+ let commitSha = null
1297
+ try { commitSha = execFileSync('git', ['-C', entry.cwd || process.cwd(), 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim() } catch { /* no commits yet */ }
1298
+ bcast('flow-task-done', { term: id, flowId: entry.flowSessionId, taskKey: entry.flowTaskKey, laneId: id, commitSha })
1299
+ process.stderr.write(`\n ${A.cyan}◆ flow slice done — ${entry.flowTaskKey} (${commitSha ? commitSha.slice(0, 8) : 'no commit'})${A.rst}\n`)
1300
+ return okText(`Slice "${entry.flowTaskKey}" marked done${commitSha ? ` (commit ${commitSha.slice(0, 8)})` : ' (no commit yet — commit, then re-call)'}. The room records it + dispatches any slices that depended on you.`)
1301
+ },
1302
+ ),
1186
1303
  ],
1187
1304
  })
1188
1305
  entry.session = startClaudeSession({
1189
- cwd: process.cwd(), model, resume, mode,
1306
+ cwd: cwd || process.cwd(), model, resume, mode, rolePrompt,
1190
1307
  mcpServers: { thinkpool: peekServer },
1191
1308
  // Tier C precheck — the PreToolUse gate calls this BEFORE raising a card, so a
1192
1309
  // disabled/looping/over-cap post never bothers a person. Closes over `entry`.
1193
1310
  crossPostGate: () => crossPostDecision({ hop: entry.hop || 0, postCount: entry.postCount || 0, disabled: process.env.TP_CROSSPOST_OFF === '1' }),
1194
- env: { ...process.env, TP_MOCKUP_OUTBOX: mockupOutbox },
1311
+ // Tier 3 — the SENDER-side gate for post_to_session (the receipt card lives in
1312
+ // the target room). roomHop/crossRoomPostCount reset on a real human turn (code-turn).
1313
+ crossRoomPostGate: () => crossRoomPostDecision({ roomHop: entry.roomHop || 0, postCount: entry.crossRoomPostCount || 0, disabled: process.env.TP_PAIRBUS_OFF === '1' }),
1314
+ env: { ...process.env, ...buildConductorEnv({ flowSessionId, mode }), TP_MOCKUP_OUTBOX: mockupOutbox },
1195
1315
  onEvent: (evt) => {
1196
1316
  // Self-heal a stale resume — the saved SDK session expired. Reopen fresh,
1197
1317
  // keeping the transcript (scrollback survives; live context is gone).
@@ -1249,6 +1369,16 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy }) {
1249
1369
  emitTail(evt)
1250
1370
  },
1251
1371
  requestPermission: (req) => new Promise((resolve) => {
1372
+ // FLOW conductor plan → intercept (no generic card). The plan JSON rides
1373
+ // ExitPlanMode's `plan` arg. Broadcast to the room; its useFlow persists the
1374
+ // task-graph + flips status → awaiting_approval → FlowPlanCard renders. Park
1375
+ // the conductor ('keep' = stay in plan mode, idle; lane dispatch is Step 2).
1376
+ if (entry.flowSessionId && req.risk === 'plan') {
1377
+ bcast('flow-plan', { term: id, flowId: entry.flowSessionId, plan: req.plan || '' })
1378
+ process.stderr.write(`\n ${A.mag}◆ flow plan ready — flow ${String(entry.flowSessionId).slice(0, 8)}.${A.rst}\n`)
1379
+ resolve('keep')
1380
+ return
1381
+ }
1252
1382
  // Keep the broadcast payload with the resolver so a reconnect can re-send it
1253
1383
  // (replay-request handler) — pending cards never enter the replayed event log.
1254
1384
  const payload = { term: id, id: req.id, toolName: req.toolName, input: req.input, risk: req.risk, plan: req.plan, questions: req.questions }
@@ -1364,6 +1494,63 @@ channel
1364
1494
  }
1365
1495
  shutdown()
1366
1496
  })
1497
+ .on('broadcast', { event: 'flow-start' }, ({ payload }) => {
1498
+ // Ensemble Flow — the room asks this bridge to launch a CONDUCTOR for a Flow run.
1499
+ // The room already created the flow_sessions row (flowId); the conductor opens in
1500
+ // plan mode (so ExitPlanMode is permitted), seeded with the build prompt. It
1501
+ // decomposes → calls ExitPlanMode(planJson) → the requestPermission flow-plan branch
1502
+ // broadcasts flow-plan back. `host` routes to one bridge in multi-machine rooms.
1503
+ if (!payload?.flowId) return
1504
+ if (payload.host && payload.host !== name) return
1505
+ for (const [, e] of sessions) if (e.flowSessionId === payload.flowId) return // already conducting this flow
1506
+ const cid = randomUUID()
1507
+ termNames[cid] = `Flow · ${String(payload.flowId).slice(0, 6)}`
1508
+ saveNames(room, termNames)
1509
+ openStructured({ id: cid, mode: 'plan', rolePrompt: FLOW_CONDUCTOR_PROMPT, flowSessionId: payload.flowId })
1510
+ const ce = sessions.get(cid)
1511
+ if (ce?.session) { try { ce.session.sendTurn(payload.prompt || '') } catch { /* session still starting */ } }
1512
+ process.stderr.write(`\n ${A.mag}◆ flow conductor launched — flow ${String(payload.flowId).slice(0, 8)} (plan mode).${A.rst}\n`)
1513
+ announce()
1514
+ })
1515
+ .on('broadcast', { event: 'flow-dispatch' }, ({ payload }) => {
1516
+ // Ensemble Flow — the room approved the plan + sends the READY tasks. Spawn one
1517
+ // worktree-isolated lane per task (FLOW_LANE_PROMPT, its own git tree so lanes
1518
+ // never collide — M57). Seed each lane with its slice spec, then echo the
1519
+ // assignments back so the room marks the tasks dispatched + flips the flow to
1520
+ // building (DB writes stay room-side, member-authed).
1521
+ if (!payload?.flowId || !Array.isArray(payload.tasks)) return
1522
+ if (payload.host && payload.host !== name) return
1523
+ const assignments = []
1524
+ for (const t of payload.tasks) {
1525
+ try {
1526
+ const { dir } = createFlowWorktree({ flowId: payload.flowId, taskKey: t.task_key })
1527
+ const laneId = randomUUID()
1528
+ termNames[laneId] = `Flow · ${t.task_key}`
1529
+ saveNames(room, termNames)
1530
+ openStructured({ id: laneId, cwd: dir, mode: 'acceptEdits', rolePrompt: FLOW_LANE_PROMPT, flowSessionId: payload.flowId, flowTaskKey: t.task_key, spawnedBy: `flow:${payload.flowId}` })
1531
+ const le = sessions.get(laneId)
1532
+ if (le?.session) {
1533
+ const spec =
1534
+ `[Flow lane — slice "${t.task_key}" of flow ${String(payload.flowId).slice(0, 8)}]\n` +
1535
+ `TITLE: ${t.title}\n` +
1536
+ `SCOPE (files you OWN — edit ONLY these): ${t.scope || '(none stated)'}\n` +
1537
+ `ACCEPTANCE (done = this runs + proves it): ${t.acceptance || '(meet the title)'}\n` +
1538
+ (t.deps && t.deps.length ? `DEPENDS ON (already built): ${t.deps.join(', ')}\n` : '') +
1539
+ `\nProject (context): ${payload.flowPrompt || ''}\n\n` +
1540
+ `Build your slice. Own only your files. Done = it runs + meets acceptance. Commit when done.`
1541
+ try { le.session.sendTurn(spec) } catch { /* session still starting */ }
1542
+ }
1543
+ assignments.push({ task_key: t.task_key, laneId })
1544
+ } catch (e) {
1545
+ process.stderr.write(`\n ${A.yel}◆ flow dispatch failed for ${t.task_key}: ${e?.message || e}${A.rst}\n`)
1546
+ }
1547
+ }
1548
+ if (assignments.length) {
1549
+ bcast('flow-dispatched', { term: 'flow', flowId: payload.flowId, assignments })
1550
+ process.stderr.write(`\n ${A.mag}◆ flow dispatched ${assignments.length} lane(s) — flow ${String(payload.flowId).slice(0, 8)}.${A.rst}\n`)
1551
+ announce()
1552
+ }
1553
+ })
1367
1554
  .on('broadcast', { event: 'term-open' }, ({ payload }) => {
1368
1555
  if (!payload?.id || !payload?.cmd) return
1369
1556
  // Multi-bridge rooms: a targeted open is for ONE machine. Untargeted
@@ -1500,7 +1687,9 @@ channel
1500
1687
  s.peekCount = 0
1501
1688
  s.postCount = 0
1502
1689
  s.pairPeekCount = 0 // cross-room read budget resets with the in-room ones (Tier 1)
1690
+ s.crossRoomPostCount = 0 // Tier 3 cross-room post budget resets on a real human turn
1503
1691
  s.hop = 0
1692
+ s.roomHop = 0 // a human turn is room-hop 0 — clears any injected cross-room hop depth
1504
1693
  const text = String(payload.text)
1505
1694
  // Echo the turn so BOTH readers (and late joiners, via the log) show who
1506
1695
  // 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, rolePrompt }) {
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
@@ -406,6 +423,7 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
406
423
  // a bypass session still planned. Bias the room session to DO the work; only
407
424
  // plan when the room is actually in Plan mode (⇧⇥) or the user explicitly asks.
408
425
  appendSystemPrompt: [
426
+ ...(rolePrompt ? [rolePrompt] : []),
409
427
  'ENVIRONMENT (authoritative — overrides any user-global CLAUDE.md or memory that claims otherwise): You are Claude running inside a ThinkPool Code room, driven live by a user (and possibly a partner) from a phone or browser, via the thinkpool-pair bridge.',
410
428
  'You are NOT in cmux. cmux may be installed on this machine and appear in $PATH, but it did not spawn you and its automation/orchestration rules do not apply. If any loaded memory says "Claude Code lives inside cmux," it is describing direct terminal use, not this room — disregard it here. The runtime truth is the env: THINKPOOL_PAIR_ACCOUNT_CHILD / npm_lifecycle_script=thinkpool-pair mark a ThinkPool Code session.',
411
429
  'Bias strongly toward DOING the work, not planning it. For a normal "add / fix / change / build X" request, just make the change directly.',
@@ -420,7 +438,8 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
420
438
  '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
439
  '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
440
  '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.',
441
+ '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.',
442
+ '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
443
  '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
444
  ].join(' '),
426
445
  // 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
  }
@@ -0,0 +1,91 @@
1
+ // Thinkpool Flow — the conductor brain.
2
+ //
3
+ // The conductor is a Claude agent session (run via startClaudeSession with
4
+ // `rolePrompt: FLOW_CONDUCTOR_PROMPT`) whose Step-1 job is to DECOMPOSE a "build me
5
+ // X" prompt into a task-graph of RUNNABLE slices + dependencies, then submit that
6
+ // plan for human approval. It does not build or spawn lanes yet — dispatch is Step 2.
7
+ // The plan lives in the task-graph store (DB), not in the conductor's chat context
8
+ // (research move #1). Spec: docs/specs/2026-06-29-thinkpool-flow.md.
9
+
10
+ // Bridge-local MIRROR of src/lib/flow/taskGraph.js — NOT a cross-package import.
11
+ // The bridge ships as its own npm package (thinkpool-pair); `../src/...` does not
12
+ // exist in the published tarball, only in a checkout. flow-task-graph.mjs is a
13
+ // byte-identical copy, kept honest by bridge/flow-task-graph.sync.test.js (fails if
14
+ // it drifts from the canonical src module). One logical source, two reachable copies.
15
+ import { FLOW_MODE, FLOW_STATUS, normalizePlanOutput } from './flow-task-graph.mjs'
16
+
17
+ // Re-export the shared plan normalizer so bridge callers import Flow pieces from one
18
+ // place. The implementation lives in the shared model (src/lib/flow/taskGraph.js).
19
+ export { normalizePlanOutput }
20
+
21
+ // Prepended to the room's standard appendSystemPrompt for a Flow conductor session.
22
+ // This prompt IS the moat — decomposition intelligence. Grounded in the Flow research
23
+ // (docs/specs/2026-06-29-thinkpool-flow.md §Research sources): runtime-per-lane is the
24
+ // moat, partition by file ownership, plan-in-store-not-context, right-size the slice
25
+ // count (3 focused beats 7 scattered), explicit acyclic deps.
26
+ export const FLOW_CONDUCTOR_PROMPT = [
27
+ 'THINKPOOL FLOW — you are the CONDUCTOR of an ensemble build. Your job RIGHT NOW (Step 1) is to decompose the user\'s build request into a TASK-GRAPH: a set of RUNNABLE slices with explicit dependencies, then submit that plan for human approval. Do NOT start building and do NOT spawn lanes yet — that happens only after the user approves the plan (Step 2+).',
28
+
29
+ 'DECOMPOSE INTO RUNNABLE SLICES. Each slice must be something ONE lane can build, RUN in its own browser-side WebContainer sandbox, observe, and self-correct INDEPENDENTLY. A slice that cannot run on its own is not a slice — split it or merge it until it is. The runtime (a lane running what it built) is the moat; slices that can\'t run break the whole model.',
30
+
31
+ 'PARTITION BY FILE OWNERSHIP. Each slice MUST own a disjoint set of files/components — state the exact paths in `scope`. Two lanes editing the same file collide (a branch is NOT isolation when two lanes share a tree). If two slices want the same file, one depends on the other, or you split the file. State ownership precisely.',
32
+
33
+ 'EXPLICIT ACYCLIC DEPENDENCIES. Every slice lists the keys of slices it depends on. Dependencies must form a DAG. A slice needing another\'s output (an API contract, a shared type, a layout component) depends on it. Minimize coupling — prefer independent slices that build in parallel.',
34
+
35
+ 'ACCEPTANCE CRITERIA per slice: how does the lane know it is DONE AND RUNNING? A lane is done when its slice runs in its WebContainer and meets the criteria — not when it has merely written code. State the observable, runnable proof (e.g. "GET /api/todos returns 200 + []", "submitting the form adds a row to the list that persists across reload").',
36
+
37
+ 'RIGHT-SIZE THE SLICE COUNT. Bias to FEW slices that each carry real weight, not many tiny ones. For a small app: 2-3 slices. Concurrent lanes are bounded (a low dispatch ceiling), so a flat fan-out of well-chosen slices beats a sprawling one. Three focused slices outperform seven scattered ones.',
38
+
39
+ 'SUBMIT VIA ExitPlanMode — when the decomposition is ready, CALL the ExitPlanMode tool, passing your plan as ONE JSON object (a JSON string) in its `plan` argument. The room intercepts that call, validates the plan is a real DAG (acyclic, all deps resolve), persists it to the task-graph store, and shows a plan-approval card. A malformed plan is rejected and you will be asked to re-emit. Do not also narrate the plan in prose. The exact shape:',
40
+ '{ "summary": "<one line: what we are building>", "tasks": [ { "key": "<short-unique-kebab-key>", "title": "<imperative title>", "scope": "<files/components this lane owns>", "acceptance": "<runnable proof it is done>", "deps": ["<other-task-key>", ...], "sliceType": "feature|scaffold|review|fix" } ] }',
41
+ '`key` values are stable short kebab-case identifiers. `deps` references other tasks\' keys. Put ONLY the JSON in the ExitPlanMode plan argument (no surrounding prose, no markdown fences).',
42
+
43
+ 'PLAN LIVES IN THE STORE, NOT THE CHAT. Your JSON plan is the artifact — it gets persisted to the task-graph (flow_sessions / flow_tasks) and rendered as an approval card. Do not ALSO narrate a long plan in prose. You may add 1-2 sentences framing the decomposition choice (why these slices, what was coupled), nothing more.',
44
+
45
+ 'MODE-AWARE. The user picked a mode — steer (watch every lane live), guide (DEFAULT: approve this plan, then review at phase boundaries), or autopilot (full-auto with a budget cap + adaptive escalation that pulls them in only if a lane stalls). Produce the full plan now regardless; the mode changes how much the human intervenes later, not how you decompose.',
46
+ ].join(' ')
47
+
48
+ // Env vars handed to a conductor session via startClaudeSession({ env }). The conductor
49
+ // reads these to know which Flow run it owns + what mode the human picked.
50
+ export function buildConductorEnv ({ flowSessionId, mode, budgetCapAutopilot = null }) {
51
+ return {
52
+ TP_FLOW_SESSION: flowSessionId,
53
+ TP_FLOW_MODE: mode,
54
+ ...(mode === FLOW_MODE.autopilot && budgetCapAutopilot != null
55
+ ? { TP_FLOW_BUDGET_CAP: String(budgetCapAutopilot) }
56
+ : {}),
57
+ }
58
+ }
59
+
60
+ // Status the FlowSession moves to once the conductor emits + the client validates a
61
+ // plan (i.e. the plan-approval card can now be shown). Kept here so bridge + client
62
+ // import the same transition target.
63
+ export const PLAN_READY_STATUS = FLOW_STATUS.awaiting_approval
64
+
65
+ // ── Lane builder ────────────────────────────────────────────────────────────
66
+ // The conductor DECOMPOSES; a lane BUILDS one slice. Lanes dispatch after the human
67
+ // approves the plan (Step 2). Each lane runs in its own git worktree (disjoint tree,
68
+ // atomic-revert target). This prompt is the lane's rolePrompt (via startClaudeSession).
69
+ export const FLOW_LANE_PROMPT = [
70
+ 'THINKPOOL FLOW — you are ONE LANE in an ensemble build. The conductor already decomposed the project into RUNNABLE slices; you own exactly ONE. Your task (scope + acceptance + deps) arrives as your first message.',
71
+
72
+ 'OWN YOUR FILES ONLY. The `scope` names the files/components this lane OWNS — they are DISJOINT from every other lane. Edit ONLY those. Touching another lane\'s files is a collision that breaks the merge — never do it. If you genuinely need a file outside your scope, STOP and say so instead of editing it.',
73
+
74
+ 'DONE MEANS RUNS. You are not done when you\'ve written code — you\'re done when your slice RUNS and meets the `acceptance` criteria (the observable, runnable proof). Verify it yourself before declaring done. State the proof (the command, the output) when you finish.',
75
+
76
+ 'COMMIT WHEN DONE, THEN SIGNAL. You are in your own git worktree on your own branch. When your slice meets acceptance — it RUNS and you have verified the proof — commit it atomically (one focused commit); that commit is the revert target if review later rejects it. Then call the `mark_flow_done` tool ONCE: it records your commit as this slice\'s revert target and tells the room the slice is done (which unblocks any slices depending on you). Do not push. Do NOT call mark_flow_done before your slice actually runs + meets acceptance.',
77
+
78
+ 'STAY IN YOUR LANE. Do NOT spawn further lanes (one hop only — the fork-bomb breaker). Do NOT edit other worktrees. If you\'re blocked on a dependency that isn\'t ready, say so + stop — another lane is building it.',
79
+
80
+ 'BE RIGOROUS, NOT VIBES. Trace the data path, root-cause before fixing, verify before claiming done. A lane that ships a guess costs the whole ensemble.',
81
+ ].join(' ')
82
+
83
+ // Env vars handed to a lane session so it knows its Flow run + which task it owns.
84
+ export function buildLaneEnv ({ flowSessionId, taskKey, laneId }) {
85
+ return {
86
+ TP_FLOW_SESSION: flowSessionId,
87
+ TP_FLOW_TASK_KEY: taskKey,
88
+ TP_FLOW_LANE_ID: laneId,
89
+ }
90
+ }
91
+
@@ -0,0 +1,166 @@
1
+ // Thinkpool Flow — task-graph data model.
2
+ //
3
+ // A Flow run = one FlowSession (the "build me X" prompt + mode + status) decomposed
4
+ // by the conductor into FlowTasks (RUNNABLE slices + dependencies). The plan lives
5
+ // HERE, in the store, not in the conductor's chat context — research move #1
6
+ // (docs/specs/2026-06-29-thinkpool-flow.md): "move the plan out of the context
7
+ // window into a task-graph." Pure module — shared by the bridge (conductor) and the
8
+ // client (room UI), and mirrored by CHECK constraints in the
9
+ // 20260629120000_flow_sessions_tasks migration (one source of truth).
10
+
11
+ export const FLOW_STATUS = {
12
+ planning: 'planning', // conductor is decomposing
13
+ awaiting_approval: 'awaiting_approval', // plan submitted, waiting on the human
14
+ approved: 'approved', // human approved; lanes may dispatch (Step 2+)
15
+ building: 'building', // lanes are running
16
+ reviewing: 'reviewing', // adversarial-review phase
17
+ assembling: 'assembling', // merging worktrees
18
+ done: 'done',
19
+ failed: 'failed',
20
+ cancelled: 'cancelled',
21
+ }
22
+
23
+ export const TASK_STATUS = {
24
+ pending: 'pending',
25
+ ready: 'ready', // deps satisfied, awaiting dispatch
26
+ dispatched: 'dispatched', // a lane is running it
27
+ reviewing: 'reviewing', // adversarial-review lane is checking it
28
+ done: 'done',
29
+ failed: 'failed',
30
+ reverted: 'reverted', // rolled back by the atomic-revert net
31
+ }
32
+
33
+ export const FLOW_MODE = {
34
+ steer: 'steer', // human watches every lane, live
35
+ guide: 'guide', // phase-gated (DEFAULT): approve plan + phase reviews
36
+ autopilot: 'autopilot', // full-auto, adaptive escalation + budget cap
37
+ }
38
+
39
+ export const SLICE_TYPE = {
40
+ feature: 'feature',
41
+ scaffold: 'scaffold', // project bootstrap / shared foundation
42
+ review: 'review', // adversarial-review lane
43
+ fix: 'fix',
44
+ }
45
+
46
+ // A FlowTask slice. Each is RUNNABLE — a lane can build + run + self-correct it in
47
+ // its own WebContainer (research: the runtime is the moat). Partition by file
48
+ // ownership (scope) so two lanes never edit the same file (a branch is NOT isolation).
49
+ export function makeTask ({ key, title, scope = '', acceptance = '', deps = [], sliceType = SLICE_TYPE.feature }) {
50
+ if (!key || typeof key !== 'string') throw new Error('task key required (string)')
51
+ if (!title) throw new Error('task title required')
52
+ if (!Array.isArray(deps)) throw new Error('deps must be an array of task keys')
53
+ if (!Object.values(SLICE_TYPE).includes(sliceType)) throw new Error(`unknown sliceType: ${sliceType}`)
54
+ return {
55
+ key,
56
+ title,
57
+ scope, // files/components this lane OWNS (disjoint from other slices)
58
+ acceptance, // runnable proof the lane + reviewer use to call it done
59
+ deps, // task keys this depends on (must form a DAG)
60
+ sliceType,
61
+ status: TASK_STATUS.pending,
62
+ laneId: null, // the spawned lane's terminal id, once dispatched
63
+ commitSha: null, // per-lane atomic commit (revert target)
64
+ failCount: 0, // adaptive-escalation counter (Autopilot pulls human in at N)
65
+ }
66
+ }
67
+
68
+ // Validate that tasks form a real DAG: unique keys, every dep references an existing
69
+ // key, no self-deps, no cycles. Throws on invalid. Returns the tasks (chainable).
70
+ export function validateDag (tasks) {
71
+ if (!Array.isArray(tasks) || tasks.length === 0) throw new Error('at least one task required')
72
+ const keys = new Set(tasks.map(t => t.key))
73
+ if (keys.size !== tasks.length) throw new Error('duplicate task keys')
74
+ const byKey = new Map(tasks.map(t => [t.key, t]))
75
+ for (const t of tasks) {
76
+ if (t.deps.includes(t.key)) throw new Error(`task "${t.key}" depends on itself`)
77
+ for (const d of t.deps) {
78
+ if (!keys.has(d)) throw new Error(`task "${t.key}" depends on unknown task "${d}"`)
79
+ }
80
+ }
81
+ // Cycle detection (DFS, three-color). byKey guaranteed complete by the checks above.
82
+ const WHITE = 0, GRAY = 1, BLACK = 2
83
+ const color = new Map(tasks.map(t => [t.key, WHITE]))
84
+ function visit (k) {
85
+ color.set(k, GRAY)
86
+ for (const d of byKey.get(k).deps) {
87
+ const c = color.get(d)
88
+ if (c === GRAY) throw new Error(`cycle detected at "${k}" → "${d}"`)
89
+ if (c === WHITE) visit(d)
90
+ }
91
+ color.set(k, BLACK)
92
+ }
93
+ for (const t of tasks) if (color.get(t.key) === WHITE) visit(t.key)
94
+ return tasks
95
+ }
96
+
97
+ // Tasks whose deps are all DONE → ready to dispatch. Pure.
98
+ export function readyTasks (tasks) {
99
+ const done = new Set(tasks.filter(t => t.status === TASK_STATUS.done).map(t => t.key))
100
+ return tasks.filter(t =>
101
+ t.status === TASK_STATUS.pending &&
102
+ t.deps.every(d => done.has(d))
103
+ )
104
+ }
105
+
106
+ // Topological order (Kahn's algorithm). Returns keys. Throws if not a DAG.
107
+ export function topoOrder (tasks) {
108
+ validateDag(tasks)
109
+ const indeg = new Map(tasks.map(t => [t.key, t.deps.length]))
110
+ const dependents = new Map(tasks.map(t => [t.key, []])) // dep-key → keys that depend on it
111
+ for (const t of tasks) for (const d of t.deps) dependents.get(d).push(t.key)
112
+ const queue = tasks.filter(t => indeg.get(t.key) === 0).map(t => t.key)
113
+ const order = []
114
+ while (queue.length) {
115
+ const k = queue.shift()
116
+ order.push(k)
117
+ for (const m of dependents.get(k)) {
118
+ indeg.set(m, indeg.get(m) - 1)
119
+ if (indeg.get(m) === 0) queue.push(m)
120
+ }
121
+ }
122
+ if (order.length !== tasks.length) throw new Error('cycle detected (topo incomplete)')
123
+ return order
124
+ }
125
+
126
+ // A FlowSession (the run). `tasks` is the decomposed plan (FlowTask[]).
127
+ export function makeFlowSession ({ prompt, mode = FLOW_MODE.guide, sessionRef = null, budgetCapAutopilot = null }) {
128
+ if (!prompt) throw new Error('prompt required')
129
+ if (!Object.values(FLOW_MODE).includes(mode)) throw new Error(`unknown mode: ${mode}`)
130
+ return {
131
+ prompt,
132
+ mode,
133
+ sessionRef, // the /code room session id this Flow runs in
134
+ status: FLOW_STATUS.planning,
135
+ budgetCapAutopilot, // token/step cap (Autopilot only); null in guide/steer
136
+ createdAt: null, // stamped by the DB
137
+ tasks: [],
138
+ }
139
+ }
140
+
141
+ // Normalize the conductor's raw plan output into a validated { summary, tasks }.
142
+ // Accepts a parsed object OR a JSON string (optionally ```json-fenced). Throws if the
143
+ // tasks don't form a real DAG. Lives in this shared module so neither the bridge nor
144
+ // the client has to import across the bridge↔src boundary.
145
+ export function normalizePlanOutput (raw) {
146
+ let obj = raw
147
+ if (typeof raw === 'string') {
148
+ let s = raw.trim()
149
+ const fence = s.match(/```(?:json)?\s*([\s\S]*?)```/i)
150
+ if (fence) s = fence[1].trim()
151
+ obj = JSON.parse(s)
152
+ }
153
+ if (!obj || typeof obj !== 'object') throw new Error('plan output is not an object')
154
+ const summary = typeof obj.summary === 'string' && obj.summary.trim() ? obj.summary.trim() : ''
155
+ if (!Array.isArray(obj.tasks) || obj.tasks.length === 0) throw new Error('plan has no tasks')
156
+ const tasks = obj.tasks.map((t) => makeTask({
157
+ key: String(t.key),
158
+ title: String(t.title ?? t.key),
159
+ scope: String(t.scope ?? ''),
160
+ acceptance: String(t.acceptance ?? ''),
161
+ deps: Array.isArray(t.deps) ? t.deps.map(String) : [],
162
+ sliceType: Object.values(SLICE_TYPE).includes(t.sliceType) ? t.sliceType : SLICE_TYPE.feature,
163
+ }))
164
+ validateDag(tasks)
165
+ return { summary, tasks }
166
+ }
@@ -0,0 +1,53 @@
1
+ // Thinkpool Flow — per-lane git worktree isolation.
2
+ //
3
+ // A Flow task-lane builds its slice in its OWN worktree. A branch is NOT isolation
4
+ // when ≥2 lanes share one working tree (M57) — two lanes editing files in the shared
5
+ // checkout collide. A worktree gives each lane a disjoint tree + its own branch, and
6
+ // that branch is the atomic-revert target (commit_sha on flow_tasks, Step 4).
7
+ //
8
+ // Pure over injected git/fs so it's unit-testable against a temp repo.
9
+ // Spec: docs/specs/2026-06-29-thinkpool-flow.md (Step 2).
10
+
11
+ import { execFileSync } from 'node:child_process'
12
+ import path from 'node:path'
13
+ import fs from 'node:fs'
14
+
15
+ const ROOT = process.cwd() // the bridge's checkout (the shared main repo root)
16
+
17
+ // The lane's branch + directory for a (flowId, taskKey). Deterministic so a
18
+ // re-dispatch of the same task reuses the same worktree.
19
+ export function worktreeSpec ({ flowId, taskKey, root = ROOT }) {
20
+ const wtRoot = path.join(root, '.claude', 'worktrees')
21
+ const short = String(flowId).replace(/-/g, '').slice(0, 8)
22
+ const safeKey = String(taskKey).toLowerCase().replace(/[^a-z0-9-]/g, '-').slice(0, 40).replace(/^-+|-+$/g, '') || 'task'
23
+ const branch = `flow/${short}/${safeKey}`
24
+ const dir = path.join(wtRoot, `flow-${short}-${safeKey}`)
25
+ return { branch, dir, wtRoot }
26
+ }
27
+
28
+ // Create a worktree for a task-lane off `base` (default origin/main). Idempotent: if
29
+ // the dir already holds a worktree, reuse it. Returns { dir, branch, created }.
30
+ export function createFlowWorktree ({ flowId, taskKey, base = 'origin/main', root = ROOT, git = runGit }) {
31
+ const { branch, dir, wtRoot } = worktreeSpec({ flowId, taskKey, root })
32
+ if (fs.existsSync(path.join(dir, '.git'))) return { dir, branch, created: false }
33
+ fs.mkdirSync(wtRoot, { recursive: true })
34
+ try {
35
+ git(['worktree', 'add', '-b', branch, dir, base], root)
36
+ } catch {
37
+ // Branch already exists (a prior dispatch of this task) — check it out instead.
38
+ git(['worktree', 'add', dir, branch], root)
39
+ }
40
+ return { dir, branch, created: true }
41
+ }
42
+
43
+ // Remove a lane's worktree + delete its branch (on revert / cancel / flow cancel).
44
+ // Best-effort: missing worktree/branch is not an error.
45
+ export function removeFlowWorktree ({ flowId, taskKey, root = ROOT, git = runGit }) {
46
+ const { branch, dir } = worktreeSpec({ flowId, taskKey, root })
47
+ try { git(['worktree', 'remove', dir, '--force'], root) } catch { /* already gone */ }
48
+ try { git(['branch', '-D', branch], root) } catch { /* already gone */ }
49
+ }
50
+
51
+ function runGit (args, cwd) {
52
+ return execFileSync('git', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8' })
53
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.95",
3
+ "version": "0.7.97",
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,9 @@
16
16
  "transcript-sanitize.mjs",
17
17
  "session-store.mjs",
18
18
  "cross-terminal.mjs",
19
+ "flow-conductor.mjs",
20
+ "flow-worktree.mjs",
21
+ "flow-task-graph.mjs",
19
22
  "serve-dir.mjs",
20
23
  "service.mjs",
21
24
  "presence.mjs",