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.
Files changed (2) hide show
  1. package/bridge.mjs +81 -63
  2. 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 (channel.channelAdapter?.canPush?.() ?? true) {
533
- channel.send({ type: 'broadcast', event, payload })
541
+ if (ch.channelAdapter?.canPush?.() ?? true) {
542
+ ch.send({ type: 'broadcast', event, payload })
534
543
  } else {
535
- channel.httpSend(event, payload).catch(() => { /* offline — replay covers it */ })
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).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.97",
3
+ "version": "0.7.98",
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": {