thinkpool-pair 0.7.315 → 0.7.316

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 -25
  2. package/package.json +1 -1
package/bridge.mjs CHANGED
@@ -1002,6 +1002,38 @@ function advanceStructuredTurn(entry, now = Date.now()) {
1002
1002
  return true
1003
1003
  }
1004
1004
 
1005
+ // Every bridge-authored task must cross the same lifecycle boundary as a
1006
+ // browser-authored code-turn. Runtime adapters claim `turnActive` synchronously,
1007
+ // but the roster deliberately trusts `_busyAnn`; calling sendTurn directly can
1008
+ // therefore leave a real turn painted idle until its first provider event.
1009
+ // Keep send + revision + visible echo + rejection boundary + announce atomic so
1010
+ // cross-posts, spawned tasks and other non-composer ingress cannot drift again.
1011
+ function dispatchStructuredTurn(entry, text, {
1012
+ options,
1013
+ visibleEvent,
1014
+ rejectionMessage = 'The agent did not accept this turn; retry after the lane is available.',
1015
+ } = {}) {
1016
+ if (!entry?.session) return { accepted: false, error: new Error('session unavailable') }
1017
+ syncStructuredTurn(entry)
1018
+ let accepted = false
1019
+ let error = null
1020
+ try { accepted = entry.session.sendTurn(text, options) !== false }
1021
+ catch (cause) { error = cause }
1022
+ if (accepted) beginStructuredTurn(entry)
1023
+ else syncStructuredTurn(entry)
1024
+ if (visibleEvent) {
1025
+ pushLog(entry, visibleEvent)
1026
+ bcast('code-event', { term: entry.id, evt: visibleEvent })
1027
+ }
1028
+ if (!accepted) {
1029
+ const failed = { kind: 'error', message: rejectionMessage, recoverable: true }
1030
+ pushLog(entry, failed)
1031
+ bcast('code-event', { term: entry.id, evt: failed })
1032
+ }
1033
+ announce()
1034
+ return { accepted, error }
1035
+ }
1036
+
1005
1037
  function dispatchPendingSideContexts(entry) {
1006
1038
  if (!entry?.pendingSideContexts?.length || typeof entry.session?.sendTurn !== 'function') return false
1007
1039
  const outcome = dispatchSideContexts({
@@ -1436,8 +1468,13 @@ async function receiveCrossRoomPost({ fromRoom, fromHost, fromTerminalName, text
1436
1468
  const msg = `[From: room ${fromLabel} — relayed via the ThinkPool cross-room Ensemble, approved by a person in this room]\n${body}`
1437
1469
  const evt = { kind: 'you', text: msg, by: `room ${fromRoom}`, crosspost: true, relaySourceName: String(fromTerminalName || '').trim().slice(0, 80) || undefined }
1438
1470
  autoNameTerminal(targetId, body)
1439
- stampEvent(evt); pushLog(te, evt); bcast('code-event', { term: targetId, evt })
1440
- try { if (te.session.sendTurn(msg) === false) return { error: `Could not deliver to room ${room} — the lane refused the turn (check its visible host-memory/runtime error).` } } catch { return { error: `Could not deliver to room ${room} — the lane may have just closed.` } }
1471
+ const dispatched = dispatchStructuredTurn(te, msg, {
1472
+ visibleEvent: evt,
1473
+ rejectionMessage: 'The incoming cross-room task was not accepted; retry after the lane is available.',
1474
+ })
1475
+ if (!dispatched.accepted) return { error: dispatched.error
1476
+ ? `Could not deliver to room ${room} — the lane may have just closed.`
1477
+ : `Could not deliver to room ${room} — the lane refused the turn (check its visible host-memory/runtime error).` }
1441
1478
  return { ok: true, ref: String(targetId).slice(0, 8) }
1442
1479
  }
1443
1480
 
@@ -2252,13 +2289,11 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2252
2289
  // No revert, no verdict broadcast, no markFlowDone — the loop stays open.
2253
2290
  if (!decision.stop) {
2254
2291
  const next = round + 1
2255
- try {
2256
- entry.session?.sendTurn(
2257
- entry.runtime === 'codex'
2258
- ? `[Flow review — round ${next}/${REVIEW_DEFAULTS.maxRounds}] The happy path held, but you have NOT reported your checks exhausted. Dig one more round, then call submit_flow_review again — pass:false with specific evidence if you break it, or pass:true AND exhausted:true only when nothing remains to check.`
2259
- : `[Flow review — round ${next}/${REVIEW_DEFAULTS.maxRounds}] The happy path held, but you have NOT reported your checks exhausted and you are under both the round and budget ceilings. Dig one more round: hunt the edge cases, the reload, the second click, concurrent use, the error path — the places the builder didn't. Then re-Write FLOW_REVIEW.json — pass:false with a specific reason if you break it, or pass:true AND exhausted:true if you genuinely have nothing left to check.`,
2260
- )
2261
- } catch { /* lane may have closed mid-verdict */ }
2292
+ dispatchStructuredTurn(entry,
2293
+ entry.runtime === 'codex'
2294
+ ? `[Flow review — round ${next}/${REVIEW_DEFAULTS.maxRounds}] The happy path held, but you have NOT reported your checks exhausted. Dig one more round, then call submit_flow_review again — pass:false with specific evidence if you break it, or pass:true AND exhausted:true only when nothing remains to check.`
2295
+ : `[Flow review — round ${next}/${REVIEW_DEFAULTS.maxRounds}] The happy path held, but you have NOT reported your checks exhausted and you are under both the round and budget ceilings. Dig one more round: hunt the edge cases, the reload, the second click, concurrent use, the error path — the places the builder didn't. Then re-Write FLOW_REVIEW.json — pass:false with a specific reason if you break it, or pass:true AND exhausted:true if you genuinely have nothing left to check.`,
2296
+ { rejectionMessage: 'The next Flow review round was not accepted; retry after the lane is available.' })
2262
2297
  process.stderr.write(`\n ${A.dim}◆ review round ${round} inconclusive — digging again (${next}/${REVIEW_DEFAULTS.maxRounds}) on ${target || entry.flowTaskKey}${A.rst}\n`)
2263
2298
  return { ok: true, message: `Round ${round} recorded (pass, not yet exhausted). Keep hunting — asked you for round ${next}/${REVIEW_DEFAULTS.maxRounds}.` }
2264
2299
  }
@@ -2673,10 +2708,13 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2673
2708
  // arrived (rides the existing code-event 'you' path — no new topic).
2674
2709
  const evt = { kind: 'you', text: msg, by: `terminal ${fromRef} (agent)`, crosspost: true, relaySourceName: termNames[id] || undefined }
2675
2710
  autoNameTerminal(target.id, args.text)
2676
- stampEvent(evt)
2677
- pushLog(te, evt)
2678
- bcast('code-event', { term: target.id, evt })
2679
- try { if (te.session.sendTurn(msg) === false) return okText(`Could not deliver to terminal ${target.ref} — it refused the turn. Check its visible host-memory/runtime error.`) } catch { return okText(`Could not deliver to terminal ${target.ref} — it may have just closed.`) }
2711
+ const dispatched = dispatchStructuredTurn(te, msg, {
2712
+ visibleEvent: evt,
2713
+ rejectionMessage: 'The cross-terminal task was not accepted; retry after the lane is available.',
2714
+ })
2715
+ if (!dispatched.accepted) return okText(dispatched.error
2716
+ ? `Could not deliver to terminal ${target.ref} — it may have just closed.`
2717
+ : `Could not deliver to terminal ${target.ref} — it refused the turn. Check its visible host-memory/runtime error.`)
2680
2718
  return okText(`Delivered to terminal ${target.ref} (${target.cmd}). It will respond in its own lane; check back with read_terminal.`)
2681
2719
  },
2682
2720
  )] : []),
@@ -2727,9 +2765,13 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2727
2765
  const msg = `[Task from main terminal ${fromRef}'s agent — opened as an independent MAIN CASCADE CONDUCTOR terminal, not an Ensemble child]\n${String(args.task).trim()}`
2728
2766
  const evt = { kind: 'you', text: msg, by: `main terminal ${fromRef} (agent)`, crosspost: true, relaySourceName: termNames[id] || undefined }
2729
2767
  autoNameTerminal(newId, args.task)
2730
- stampEvent(evt); pushLog(conductor, evt); bcast('code-event', { term: newId, evt })
2731
- try { if (conductor.session.sendTurn(msg) === false) return okText(`Opened main conductor ${newRef}, but host pressure prevented its runtime from starting. Existing lanes remain connected; free memory and retry the task.`) }
2732
- catch { return okText(`Opened main conductor ${newRef}${args?.name ? ` ("${args.name}")` : ''}, but it may still be starting — the initial task could not be delivered.`) }
2768
+ const dispatched = dispatchStructuredTurn(conductor, msg, {
2769
+ visibleEvent: evt,
2770
+ rejectionMessage: 'The initial conductor task was not accepted; retry after the lane is available.',
2771
+ })
2772
+ if (!dispatched.accepted) return okText(dispatched.error
2773
+ ? `Opened main conductor ${newRef}${args?.name ? ` ("${args.name}")` : ''}, but it may still be starting — the initial task could not be delivered.`
2774
+ : `Opened main conductor ${newRef}, but host pressure prevented its runtime from starting. Existing lanes remain connected; free memory and retry the task.`)
2733
2775
  return okText(`Opened independent main Cascade conductor ${newRef}${args?.name ? ` ("${args.name}")` : ''} and handed it the task. It is a main terminal, not an Ensemble lane.`)
2734
2776
  },
2735
2777
  )] : []),
@@ -2847,8 +2889,13 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2847
2889
  const msg = `[Task from terminal ${fromRef}'s agent — relayed via ThinkPool Ensemble; you are its WORKER SUB-TERMINAL, never a main terminal or Cascade conductor]\n${args.task}${reviewTarget}`
2848
2890
  const evt = { kind: 'you', text: msg, by: `terminal ${fromRef} (agent)`, crosspost: true, relaySourceName: termNames[id] || undefined }
2849
2891
  autoNameTerminal(newId, args.task)
2850
- stampEvent(evt); pushLog(ne, evt); bcast('code-event', { term: newId, evt })
2851
- try { if (ne.session.sendTurn(msg) === false) return okText(`Opened lane ${newRef}${args?.name ? ` ("${args.name}")` : ''}, but host pressure prevented its runtime from accepting the task. Existing lanes remain connected; free memory and retry.`) } catch { return okText(`Opened lane ${newRef}${args?.name ? ` ("${args.name}")` : ''}, but it may still be starting — could not hand off the task. Try post_to_terminal shortly.`) }
2892
+ const dispatched = dispatchStructuredTurn(ne, msg, {
2893
+ visibleEvent: evt,
2894
+ rejectionMessage: 'The initial worker task was not accepted; retry after the lane is available.',
2895
+ })
2896
+ if (!dispatched.accepted) return okText(dispatched.error
2897
+ ? `Opened lane ${newRef}${args?.name ? ` ("${args.name}")` : ''}, but it may still be starting — could not hand off the task. Try post_to_terminal shortly.`
2898
+ : `Opened lane ${newRef}${args?.name ? ` ("${args.name}")` : ''}, but host pressure prevented its runtime from accepting the task. Existing lanes remain connected; free memory and retry.`)
2852
2899
  return okText(`Opened agent lane ${newRef}${args?.name ? ` ("${args.name}")` : ''} and handed it the task. It runs in its own lane — check back with read_terminal, then close_terminal when done.`)
2853
2900
  }
2854
2901
  return okText(`Opened idle agent lane ${newRef}${args?.name ? ` ("${args.name}")` : ''}. Hand it work with post_to_terminal, or a person can type into it.`)
@@ -3897,12 +3944,15 @@ channel
3897
3944
  }
3898
3945
  child.hop = 0
3899
3946
  const childEvent = { kind: 'you', text: task, by }
3900
- stampEvent(childEvent); pushLog(child, childEvent); bcast('code-event', { term: payload.id, evt: childEvent })
3901
3947
  const parentEvent = { kind: 'side-started', sideId: payload.id, sideName, task, by }
3902
- stampEvent(parentEvent); pushLog(parent, parentEvent); bcast('code-event', { term: sideParent, evt: parentEvent })
3948
+ pushLog(parent, parentEvent); bcast('code-event', { term: sideParent, evt: parentEvent })
3903
3949
  const snapshot = sideSnapshot(parent.log.filter((event) => event.cid !== parentEvent.cid))
3904
3950
  const prompt = snapshot ? `${snapshot}\n\n--- side task ---\n${task}` : task
3905
- try { if (child.session.sendTurn(prompt) === false) throw new Error('runtime refused the turn') } catch {
3951
+ const dispatched = dispatchStructuredTurn(child, prompt, {
3952
+ visibleEvent: childEvent,
3953
+ rejectionMessage: 'The side-lane task was not accepted; retry after the lane is available.',
3954
+ })
3955
+ if (!dispatched.accepted) {
3906
3956
  bcast('side-open-failed', { id: payload.id, parent: sideParent, message: 'The side agent could not start.' })
3907
3957
  endStructured(payload.id)
3908
3958
  return
@@ -4084,8 +4134,10 @@ channel
4084
4134
  const preparing = { kind: 'control', text: 'Preparing a compact handoff for main…', by }
4085
4135
  stampEvent(preparing); pushLog(side, preparing); bcast('code-event', { term: payload.term, evt: preparing })
4086
4136
  side.flush?.(); announce()
4087
- try { side.session.sendTurn(SIDE_HANDOFF_PROMPT) }
4088
- catch {
4137
+ const dispatched = dispatchStructuredTurn(side, SIDE_HANDOFF_PROMPT, {
4138
+ rejectionMessage: 'The side-lane handoff was not accepted; retry after the lane is available.',
4139
+ })
4140
+ if (!dispatched.accepted) {
4089
4141
  side.sideHandback = null
4090
4142
  const failed = { kind: 'control', text: 'Couldn’t start the handoff — try again.', by }
4091
4143
  stampEvent(failed); pushLog(side, failed); bcast('code-event', { term: payload.term, evt: failed })
@@ -4806,7 +4858,9 @@ flowChannel
4806
4858
  // to pin a cheaper/smarter conductor. Lanes get tiered below via laneModelFor.
4807
4859
  openStructured({ id: cid, runtime: flowRuntime, model: conductorModel, mode: flowRuntime === 'codex' ? 'plan' : 'default', rolePrompt: flowRuntime === 'claude' ? FLOW_CONDUCTOR_PROMPT : FLOW_CODEX_CONDUCTOR_PROMPT, flowSessionId: payload.flowId, flowRole: 'conductor', spawnedBy: `flow:${payload.flowId}` })
4808
4860
  const ce = sessions.get(cid)
4809
- if (ce?.session) { try { ce.session.sendTurn(payload.prompt || '') } catch { /* session still starting */ } }
4861
+ if (ce?.session) dispatchStructuredTurn(ce, payload.prompt || '', {
4862
+ rejectionMessage: 'The Flow conductor task was not accepted; retry after the lane is available.',
4863
+ })
4810
4864
  process.stderr.write(`\n ${A.mag}◆ flow conductor launched — flow ${String(payload.flowId).slice(0, 8)} (plan mode).${A.rst}\n`)
4811
4865
  announce()
4812
4866
  })
@@ -4953,7 +5007,9 @@ flowChannel
4953
5007
  `\nProject (context): ${payload.flowPrompt || ''}\n\n` +
4954
5008
  `Build your slice. Own only your files. Done = it runs + meets acceptance. Commit when done.` +
4955
5009
  (flowRuntime === 'codex' || flowRuntime === 'hermes' ? ` Then call the ThinkPool mark_flow_done MCP tool; do not write FLOW_DONE.` : '')
4956
- try { le.session.sendTurn(spec) } catch { /* session still starting */ }
5010
+ dispatchStructuredTurn(le, spec, {
5011
+ rejectionMessage: `Flow slice ${t.task_key} was not accepted; retry after the lane is available.`,
5012
+ })
4957
5013
  }
4958
5014
  }
4959
5015
  assignments.push({ task_key: t.task_key, laneId })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.315",
3
+ "version": "0.7.316",
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": {