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 +189 -28
- package/bridge.mjs +196 -7
- package/claude-session.mjs +25 -6
- package/cross-terminal.mjs +58 -3
- package/flow-conductor.mjs +91 -0
- package/flow-task-graph.mjs +166 -0
- package/flow-worktree.mjs +53 -0
- package/package.json +4 -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
|
@@ -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 {
|
|
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
|
-
|
|
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).
|
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, 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 (
|
|
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
|
|
@@ -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.
|
|
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
|
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
|
}
|
|
@@ -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.
|
|
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",
|