thinkpool-pair 0.7.97 → 0.7.98
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/bridge.mjs +81 -63
- package/package.json +1 -1
package/bridge.mjs
CHANGED
|
@@ -456,6 +456,15 @@ const channel = supabase.channel(`tpcode:${room}`, {
|
|
|
456
456
|
config: { broadcast: { self: false }, presence: { key: `bridge:${name}` } },
|
|
457
457
|
})
|
|
458
458
|
|
|
459
|
+
// Thinkpool Flow rides its OWN topic, separate from the room's primary tpcode channel.
|
|
460
|
+
// The web's useFlow hook is a child of CodeRoom, so its effect runs before the room's —
|
|
461
|
+
// sharing tpcode meant useFlow created/owned/removed the room's channel and killed
|
|
462
|
+
// realtime on room open (the 2026-06-30 snag). A dedicated topic keeps the two lifecycles
|
|
463
|
+
// from colliding. The bridge is a separate client, so a second channel here is free.
|
|
464
|
+
const flowChannel = supabase.channel(`tpflow:${room}`, {
|
|
465
|
+
config: { broadcast: { self: false } },
|
|
466
|
+
})
|
|
467
|
+
|
|
459
468
|
// ── terminal registry ──────────────────────────────────────────────
|
|
460
469
|
// id → { term (pty), cmd, attached, scrollback, buf }
|
|
461
470
|
const terms = new Map()
|
|
@@ -525,14 +534,14 @@ const defendFrame = (event, payload) => {
|
|
|
525
534
|
return p
|
|
526
535
|
}
|
|
527
536
|
|
|
528
|
-
const bcast = (event, payload) => {
|
|
537
|
+
const bcast = (event, payload, ch = channel) => {
|
|
529
538
|
if (event === 'pty-out' || event === 'code-event') lastActivity = Date.now()
|
|
530
539
|
payload = defendFrame(event, payload)
|
|
531
540
|
try {
|
|
532
|
-
if (
|
|
533
|
-
|
|
541
|
+
if (ch.channelAdapter?.canPush?.() ?? true) {
|
|
542
|
+
ch.send({ type: 'broadcast', event, payload })
|
|
534
543
|
} else {
|
|
535
|
-
|
|
544
|
+
ch.httpSend(event, payload).catch(() => { /* offline — replay covers it */ })
|
|
536
545
|
}
|
|
537
546
|
} catch { /* noop */ }
|
|
538
547
|
}
|
|
@@ -1295,7 +1304,7 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rol
|
|
|
1295
1304
|
if (!entry.flowSessionId || !entry.flowTaskKey) return okText('Not a Flow lane — nothing to mark done.')
|
|
1296
1305
|
let commitSha = null
|
|
1297
1306
|
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 })
|
|
1307
|
+
bcast('flow-task-done', { term: id, flowId: entry.flowSessionId, taskKey: entry.flowTaskKey, laneId: id, commitSha }, flowChannel)
|
|
1299
1308
|
process.stderr.write(`\n ${A.cyan}◆ flow slice done — ${entry.flowTaskKey} (${commitSha ? commitSha.slice(0, 8) : 'no commit'})${A.rst}\n`)
|
|
1300
1309
|
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
1310
|
},
|
|
@@ -1374,7 +1383,7 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rol
|
|
|
1374
1383
|
// task-graph + flips status → awaiting_approval → FlowPlanCard renders. Park
|
|
1375
1384
|
// the conductor ('keep' = stay in plan mode, idle; lane dispatch is Step 2).
|
|
1376
1385
|
if (entry.flowSessionId && req.risk === 'plan') {
|
|
1377
|
-
bcast('flow-plan', { term: id, flowId: entry.flowSessionId, plan: req.plan || '' })
|
|
1386
|
+
bcast('flow-plan', { term: id, flowId: entry.flowSessionId, plan: req.plan || '' }, flowChannel)
|
|
1378
1387
|
process.stderr.write(`\n ${A.mag}◆ flow plan ready — flow ${String(entry.flowSessionId).slice(0, 8)}.${A.rst}\n`)
|
|
1379
1388
|
resolve('keep')
|
|
1380
1389
|
return
|
|
@@ -1494,63 +1503,6 @@ channel
|
|
|
1494
1503
|
}
|
|
1495
1504
|
shutdown()
|
|
1496
1505
|
})
|
|
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
|
-
})
|
|
1554
1506
|
.on('broadcast', { event: 'term-open' }, ({ payload }) => {
|
|
1555
1507
|
if (!payload?.id || !payload?.cmd) return
|
|
1556
1508
|
// Multi-bridge rooms: a targeted open is for ONE machine. Untargeted
|
|
@@ -1830,6 +1782,72 @@ channel
|
|
|
1830
1782
|
}
|
|
1831
1783
|
})
|
|
1832
1784
|
|
|
1785
|
+
// ── Thinkpool Flow control channel (tpflow:<room>) ──────────────────────────
|
|
1786
|
+
// Separate from the room channel above so the web's useFlow hook can own its own
|
|
1787
|
+
// topic without colliding with the room's tpcode channel (see the flowChannel def +
|
|
1788
|
+
// useFlow.js for the 2026-06-30 collision this fixes). Inbound: flow-start (launch a
|
|
1789
|
+
// conductor) + flow-dispatch (spawn lanes). Outbound (via bcast(..., flowChannel)):
|
|
1790
|
+
// flow-plan, flow-dispatched, flow-task-done.
|
|
1791
|
+
flowChannel
|
|
1792
|
+
.on('broadcast', { event: 'flow-start' }, ({ payload }) => {
|
|
1793
|
+
// Ensemble Flow — the room asks this bridge to launch a CONDUCTOR for a Flow run.
|
|
1794
|
+
// The room already created the flow_sessions row (flowId); the conductor opens in
|
|
1795
|
+
// plan mode (so ExitPlanMode is permitted), seeded with the build prompt. It
|
|
1796
|
+
// decomposes → calls ExitPlanMode(planJson) → the requestPermission flow-plan branch
|
|
1797
|
+
// broadcasts flow-plan back. `host` routes to one bridge in multi-machine rooms.
|
|
1798
|
+
if (!payload?.flowId) return
|
|
1799
|
+
if (payload.host && payload.host !== name) return
|
|
1800
|
+
for (const [, e] of sessions) if (e.flowSessionId === payload.flowId) return // already conducting this flow
|
|
1801
|
+
const cid = randomUUID()
|
|
1802
|
+
termNames[cid] = `Flow · ${String(payload.flowId).slice(0, 6)}`
|
|
1803
|
+
saveNames(room, termNames)
|
|
1804
|
+
openStructured({ id: cid, mode: 'plan', rolePrompt: FLOW_CONDUCTOR_PROMPT, flowSessionId: payload.flowId })
|
|
1805
|
+
const ce = sessions.get(cid)
|
|
1806
|
+
if (ce?.session) { try { ce.session.sendTurn(payload.prompt || '') } catch { /* session still starting */ } }
|
|
1807
|
+
process.stderr.write(`\n ${A.mag}◆ flow conductor launched — flow ${String(payload.flowId).slice(0, 8)} (plan mode).${A.rst}\n`)
|
|
1808
|
+
announce()
|
|
1809
|
+
})
|
|
1810
|
+
.on('broadcast', { event: 'flow-dispatch' }, ({ payload }) => {
|
|
1811
|
+
// Ensemble Flow — the room approved the plan + sends the READY tasks. Spawn one
|
|
1812
|
+
// worktree-isolated lane per task (FLOW_LANE_PROMPT, its own git tree so lanes
|
|
1813
|
+
// never collide — M57). Seed each lane with its slice spec, then echo the
|
|
1814
|
+
// assignments back so the room marks the tasks dispatched + flips the flow to
|
|
1815
|
+
// building (DB writes stay room-side, member-authed).
|
|
1816
|
+
if (!payload?.flowId || !Array.isArray(payload.tasks)) return
|
|
1817
|
+
if (payload.host && payload.host !== name) return
|
|
1818
|
+
const assignments = []
|
|
1819
|
+
for (const t of payload.tasks) {
|
|
1820
|
+
try {
|
|
1821
|
+
const { dir } = createFlowWorktree({ flowId: payload.flowId, taskKey: t.task_key })
|
|
1822
|
+
const laneId = randomUUID()
|
|
1823
|
+
termNames[laneId] = `Flow · ${t.task_key}`
|
|
1824
|
+
saveNames(room, termNames)
|
|
1825
|
+
openStructured({ id: laneId, cwd: dir, mode: 'acceptEdits', rolePrompt: FLOW_LANE_PROMPT, flowSessionId: payload.flowId, flowTaskKey: t.task_key, spawnedBy: `flow:${payload.flowId}` })
|
|
1826
|
+
const le = sessions.get(laneId)
|
|
1827
|
+
if (le?.session) {
|
|
1828
|
+
const spec =
|
|
1829
|
+
`[Flow lane — slice "${t.task_key}" of flow ${String(payload.flowId).slice(0, 8)}]\n` +
|
|
1830
|
+
`TITLE: ${t.title}\n` +
|
|
1831
|
+
`SCOPE (files you OWN — edit ONLY these): ${t.scope || '(none stated)'}\n` +
|
|
1832
|
+
`ACCEPTANCE (done = this runs + proves it): ${t.acceptance || '(meet the title)'}\n` +
|
|
1833
|
+
(t.deps && t.deps.length ? `DEPENDS ON (already built): ${t.deps.join(', ')}\n` : '') +
|
|
1834
|
+
`\nProject (context): ${payload.flowPrompt || ''}\n\n` +
|
|
1835
|
+
`Build your slice. Own only your files. Done = it runs + meets acceptance. Commit when done.`
|
|
1836
|
+
try { le.session.sendTurn(spec) } catch { /* session still starting */ }
|
|
1837
|
+
}
|
|
1838
|
+
assignments.push({ task_key: t.task_key, laneId })
|
|
1839
|
+
} catch (e) {
|
|
1840
|
+
process.stderr.write(`\n ${A.yel}◆ flow dispatch failed for ${t.task_key}: ${e?.message || e}${A.rst}\n`)
|
|
1841
|
+
}
|
|
1842
|
+
}
|
|
1843
|
+
if (assignments.length) {
|
|
1844
|
+
bcast('flow-dispatched', { term: 'flow', flowId: payload.flowId, assignments }, flowChannel)
|
|
1845
|
+
process.stderr.write(`\n ${A.mag}◆ flow dispatched ${assignments.length} lane(s) — flow ${String(payload.flowId).slice(0, 8)}.${A.rst}\n`)
|
|
1846
|
+
announce()
|
|
1847
|
+
}
|
|
1848
|
+
})
|
|
1849
|
+
.subscribe()
|
|
1850
|
+
|
|
1833
1851
|
// Watchdog — if the realtime channel is stuck (never connected, or dropped and
|
|
1834
1852
|
// not recovered) for >60s, exit non-zero so a supervisor/launchd restarts a
|
|
1835
1853
|
// clean process. Healthy long-lived connections never trip this (brokenSince=0).
|