thinkpool-pair 0.7.104 → 0.7.106

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 CHANGED
@@ -1322,6 +1322,11 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rol
1322
1322
  })
1323
1323
  entry.session = startClaudeSession({
1324
1324
  cwd: cwd || process.cwd(), model, resume, mode, rolePrompt,
1325
+ // A Flow CONDUCTOR (flowSessionId set, no flowTaskKey) must DECOMPOSE, not explore via
1326
+ // a fan-out of Task/Explore subagents — those raise permission cards in plan mode (which
1327
+ // now land on the hidden conductor terminal) and the model ignores the prompt rule not
1328
+ // to. Hard-block the Task tool for conductors so it reads with Read/Grep/Glob + ExitPlanMode.
1329
+ blockSubagents: !!flowSessionId && !flowTaskKey,
1325
1330
  mcpServers: { thinkpool: peekServer },
1326
1331
  // Tier C precheck — the PreToolUse gate calls this BEFORE raising a card, so a
1327
1332
  // disabled/looping/over-cap post never bothers a person. Closes over `entry`.
@@ -1834,7 +1839,11 @@ flowChannel
1834
1839
  const cid = randomUUID()
1835
1840
  termNames[cid] = `Flow · ${String(payload.flowId).slice(0, 6)}`
1836
1841
  saveNames(room, termNames)
1837
- openStructured({ id: cid, mode: 'plan', rolePrompt: FLOW_CONDUCTOR_PROMPT, flowSessionId: payload.flowId })
1842
+ // spawnedBy = the SAME synthetic `flow:<id>` the lanes carry, so the conductor + its
1843
+ // lanes form ONE Ensemble group (same ensembleRoot) — the drilled flow shows a row of
1844
+ // all its terminals to hop between, with a working Back. (No flowTaskKey → it's still
1845
+ // the conductor, excluded from the lane cap below.)
1846
+ openStructured({ id: cid, mode: 'plan', rolePrompt: FLOW_CONDUCTOR_PROMPT, flowSessionId: payload.flowId, spawnedBy: `flow:${payload.flowId}` })
1838
1847
  const ce = sessions.get(cid)
1839
1848
  if (ce?.session) { try { ce.session.sendTurn(payload.prompt || '') } catch { /* session still starting */ } }
1840
1849
  process.stderr.write(`\n ${A.mag}◆ flow conductor launched — flow ${String(payload.flowId).slice(0, 8)} (plan mode).${A.rst}\n`)
@@ -1851,7 +1860,7 @@ flowChannel
1851
1860
  const assignments = []
1852
1861
  // Step 6 — cap concurrent Flow lanes (invariant: ≤ FLOW_LIMITS.maxConcurrentLanes).
1853
1862
  // Overflow tasks stay pending; the room re-dispatches them in the next wave.
1854
- let liveLanes = [...sessions.values()].filter(e => typeof e.spawnedBy === 'string' && e.spawnedBy.startsWith('flow:')).length
1863
+ let liveLanes = [...sessions.values()].filter(e => typeof e.spawnedBy === 'string' && e.spawnedBy.startsWith('flow:') && e.flowTaskKey).length // lanes only — conductor has no flowTaskKey
1855
1864
  for (const t of payload.tasks) {
1856
1865
  const gate = canDispatch({ mode: payload.mode || 'guide', liveLanes, budget: { capTokens: null, spentTokens: 0, killed: false } })
1857
1866
  if (!gate.ok) { process.stderr.write(`\n ${A.yel}◆ flow dispatch held (${gate.reason}, ${liveLanes}/${FLOW_LIMITS.maxConcurrentLanes} lanes) — ${assignments.length} spawned this wave.${A.rst}\n`); break }
@@ -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, crossRoomPostGate, rolePrompt }) {
123
+ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode = 'default', onEvent, requestPermission, mcpServers, crossPostGate, crossRoomPostGate, rolePrompt, blockSubagents = false }) {
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
@@ -248,6 +248,15 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
248
248
  // Surface it to the room when it changes; absent on models without effort.
249
249
  const lvl = hookInput.effort?.level
250
250
  if (lvl && lvl !== effort) { effort = lvl; emit({ kind: 'effort', level: effort }) }
251
+ // Flow CONDUCTOR hard-stop: a conductor must DECOMPOSE, never fan out Task/Explore
252
+ // subagents. The prompt asks it not to, but the model's parallelize instinct wins and
253
+ // it spawns Explore agents anyway — which (in plan mode) raise permission cards on the
254
+ // now-hidden conductor terminal and hang the whole flow. Deny the Task tool outright and
255
+ // tell it to read with Read/Grep/Glob + ExitPlanMode. (blockSubagents is set only for
256
+ // the conductor session — lanes are governed by their own one-hop prompt rule.)
257
+ if (blockSubagents && (toolName === 'Task' || toolName === 'Agent')) {
258
+ return { continue: true, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: 'Flow conductors do NOT spawn subagents — it stalls the flow on a permission card. If you must inspect the codebase, read it yourself with Read/Grep/Glob (allowed here, no prompt). Then DECOMPOSE and call ExitPlanMode once. Do not call Task/Agent again.' } }
259
+ }
251
260
  // ThinkPool cross-terminal READ (B2) is read-only + within-room — never
252
261
  // prompt, in any mode. It still surfaces as a tool card so the room sees the peek.
253
262
  if (toolName === 'mcp__thinkpool__read_terminal') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.104",
3
+ "version": "0.7.106",
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": {